dimescheduler 0.1.0

Official Rust client library for the Dime.Scheduler API.
Documentation
use serde_json::json;
use wiremock::matchers::{body_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

use super::crud::client;
use crate::api::{NotificationListOptions, TaskListOptions};
use crate::{entities, types};

fn list() -> ResponseTemplate {
    ResponseTemplate::new(200).set_body_json(json!([]))
}

fn envelope() -> ResponseTemplate {
    ResponseTemplate::new(200).set_body_json(json!({ "success": true }))
}

#[tokio::test]
async fn appointments_get_sends_range_and_repeated_resources() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/appointment"))
        .and(query_param("startDate", "2026-01-01T00:00:00Z"))
        .and(query_param("endDate", "2026-01-31T00:00:00Z"))
        .respond_with(list())
        .expect(1)
        .mount(&server)
        .await;
    client(&server)
        .await
        .appointments
        .get(
            "2026-01-01T00:00:00Z",
            "2026-01-31T00:00:00Z",
            &["R1", "R2"],
        )
        .await
        .unwrap();
    let reqs = server.received_requests().await.unwrap();
    let q = reqs[0].url.query().unwrap_or("").to_string();
    assert!(
        q.contains("resources=R1") && q.contains("resources=R2"),
        "{q}"
    );
}

#[tokio::test]
async fn get_by_id_escapes_path() {
    let server = MockServer::start().await;
    // wiremock's `path` matcher and `Url::path()` both see the percent-encoded
    // form, so this proves the id travelled as one escaped segment.
    Mock::given(method("GET"))
        .and(path("/appointment/APT%201%2F2"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 1 })))
        .expect(1)
        .mount(&server)
        .await;
    client(&server)
        .await
        .appointments
        .get_by_id("APT 1/2")
        .await
        .unwrap();
    let raw = server.received_requests().await.unwrap()[0]
        .url
        .path()
        .to_string();
    assert_eq!(raw, "/appointment/APT%201%2F2");
}

#[tokio::test]
async fn jobs_get_by_job_no_and_keeps_crud() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/job/JOB-001"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(json!({ "id": 7, "jobNo": "JOB-001" })),
        )
        .expect(1)
        .mount(&server)
        .await;
    let c = client(&server).await;
    let dto = c.jobs.get("JOB-001").await.unwrap();
    assert_eq!(dto.job_no.as_deref(), Some("JOB-001"));
    assert_eq!(c.jobs.route(), "/job");
}

#[tokio::test]
async fn tasks_list_forwards_only_set_filters() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/task"))
        .respond_with(list())
        .mount(&server)
        .await;
    let c = client(&server).await;
    c.tasks
        .list(&TaskListOptions {
            is_open: Some(true),
            page: Some(2),
            page_size: Some(50),
        })
        .await
        .unwrap();
    c.tasks.list(&TaskListOptions::default()).await.unwrap();
    let reqs = server.received_requests().await.unwrap();
    let q0 = reqs[0].url.query().unwrap();
    assert!(
        q0.contains("isOpen=true") && q0.contains("page=2") && q0.contains("pageSize=50"),
        "{q0}"
    );
    assert_eq!(reqs[1].url.query(), None);
}

#[tokio::test]
async fn notifications_get_sends_page_limit_and_optional_filters() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/notification"))
        .and(query_param("page", "1"))
        .and(query_param("limit", "20"))
        .and(query_param("sort", "date desc"))
        .respond_with(list())
        .expect(1)
        .mount(&server)
        .await;
    client(&server)
        .await
        .notifications
        .get(
            1,
            20,
            &NotificationListOptions {
                sort: Some("date desc".into()),
                ..Default::default()
            },
        )
        .await
        .unwrap();
}

#[tokio::test]
async fn date_range_accessors_pass_dates_through() {
    for (route, call) in [
        ("/resourceCapacity", 0),
        ("/timeEntry", 1),
        ("/timeSession", 2),
    ] {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path(route))
            .and(query_param("start", "2026-03-01"))
            .and(query_param("end", "2026-03-31"))
            .respond_with(list())
            .expect(1)
            .mount(&server)
            .await;
        let c = client(&server).await;
        match call {
            0 => {
                c.resource_capacities
                    .get("2026-03-01", "2026-03-31")
                    .await
                    .unwrap();
            }
            1 => {
                c.time_entries
                    .get("2026-03-01", "2026-03-31")
                    .await
                    .unwrap();
            }
            _ => {
                c.time_sessions
                    .get("2026-03-01", "2026-03-31")
                    .await
                    .unwrap();
            }
        }
    }
}

#[tokio::test]
async fn read_only_accessors_hit_their_routes() {
    let server = MockServer::start().await;
    for r in [
        "/calendar",
        "/resourcetype",
        "/appointmentDependency",
        "/appointmentField",
    ] {
        Mock::given(method("GET"))
            .and(path(r))
            .respond_with(list())
            .expect(1)
            .mount(&server)
            .await;
    }
    let c = client(&server).await;
    c.calendars.get_all().await.unwrap();
    c.resource_types.get_all().await.unwrap();
    c.appointment_dependencies.get_all().await.unwrap();
    c.appointment_fields.get_all().await.unwrap();
}

#[tokio::test]
async fn task_budget_entries_create_and_delete() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/taskBudgetEntry"))
        .respond_with(envelope())
        .expect(1)
        .mount(&server)
        .await;
    Mock::given(method("DELETE"))
        .and(path("/taskBudgetEntry"))
        .respond_with(envelope())
        .expect(1)
        .mount(&server)
        .await;
    let c = client(&server).await;
    let e = entities::TaskBudgetEntry {
        job_no: "JOB-001".into(),
        task_no: "T-1".into(),
        delta_in_seconds: 3600,
        ..Default::default()
    };
    c.task_budget_entries.create(&e).await.unwrap();
    c.task_budget_entries.delete_many(&[e]).await.unwrap();
    let body = server.received_requests().await.unwrap()[1].body.clone();
    assert!(body.starts_with(b"["), "batch delete must send an array");
}

#[tokio::test]
async fn task_assignments_create_and_delete_single_request_with_empty_200() {
    let server = MockServer::start().await;
    let req = types::TaskAssignmentRequest {
        job_no: Some("JOB-001".into()),
        task_no: Some("T-1".into()),
        resource_no: Some("RES-1".into()),
        ..Default::default()
    };
    Mock::given(method("POST"))
        .and(path("/taskAssignment"))
        .and(body_json(
            json!({ "jobNo": "JOB-001", "taskNo": "T-1", "resourceNo": "RES-1" }),
        ))
        .respond_with(ResponseTemplate::new(200))
        .expect(1)
        .mount(&server)
        .await;
    Mock::given(method("DELETE"))
        .and(path("/taskAssignment"))
        .respond_with(ResponseTemplate::new(200))
        .expect(1)
        .mount(&server)
        .await;
    let c = client(&server).await;
    c.task_assignments.create(&req).await.unwrap();
    c.task_assignments.delete(&req).await.unwrap();
}