cinnamon 1.0.3

A type-safe Nightscout client for Rust, aiming to simplify the interactions with the confusing Nightscout API.
Documentation
use chrono::Utc;
use cinnamon::client::NightscoutClient;
use cinnamon::models::entries::SgvEntry;
use cinnamon::models::properties::PropertyType;
use cinnamon::models::treatments::Treatment;
use cinnamon::models::trends::Trend;
use cinnamon::query_builder::Device;
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

async fn get_client(mock_server: &MockServer) -> NightscoutClient {
    NightscoutClient::new(&mock_server.uri())
        .expect("Failed to create client")
        .with_secret("test-secret-123")
}

#[tokio::test]
async fn test_profile_get() {
    let mock_server = MockServer::start().await;
    let client = get_client(&mock_server).await;

    let mock_profile = json!([{
        "_id": "653b6...",
        "defaultProfile": "Default",
        "startDate": "2023-01-01T00:00:00.000Z",
        "created_at": "2023-01-01T00:00:00.000Z",
        "store": {
            "Default": {
                "dia": 3.0,
                "timezone": "UTC",
                "units": "mg/dl",
                "carbratio": [{"time": "00:00", "value": 10.0}],
                "sens": [{"time": "00:00", "value": 30.0}],
                "basal": [{"time": "00:00", "value": 1.5}],
                "target_low": [{"time": "00:00", "value": 80.0}],
                "target_high": [{"time": "00:00", "value": 120.0}]
            }
        }
    }]);

    Mock::given(method("GET"))
        .and(path("/api/v2/profile.json"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_profile))
        .mount(&mock_server)
        .await;

    let profiles = client
        .profiles()
        .get()
        .await
        .expect("Failed to fetch profiles");
    assert!(!profiles.is_empty());
    assert_eq!(profiles[0].default_profile_name, "Default");
}

#[tokio::test]
async fn test_sgv_get_limit() {
    let mock_server = MockServer::start().await;
    let client = get_client(&mock_server).await;

    let mock_sgvs = json!([
        {
            "_id": "1",
            "sgv": 120,
            "date": 1698393600000i64,
            "dateString": "2023-10-27T10:00:00Z",
            "direction": "Flat",
            "type": "sgv",
            "device": "xDrip"
        }
    ]);

    Mock::given(method("GET"))
        .and(path("/api/v2/entries/sgv.json"))
        .and(query_param("count", "5"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_sgvs))
        .mount(&mock_server)
        .await;

    let result = client
        .sgv()
        .get()
        .limit(5)
        .send()
        .await
        .expect("Failed to get SGV");
    assert_eq!(result.len(), 1);
    assert_eq!(result[0].sgv, 120);
}

#[tokio::test]
async fn test_sgv_create() {
    let mock_server = MockServer::start().await;
    let client = get_client(&mock_server).await;

    let new_entry = SgvEntry::new(150, Trend::SingleUp, Utc::now());
    let entries_vec = vec![new_entry.clone()];

    Mock::given(method("POST"))
        .and(path("/api/v2/entries.json"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!([new_entry])))
        .mount(&mock_server)
        .await;

    let created = client
        .sgv()
        .create(entries_vec)
        .await
        .expect("Failed to create SGV");
    assert_eq!(created[0].sgv, 150);
}

#[tokio::test]
async fn test_sgv_delete_by_id() {
    let mock_server = MockServer::start().await;
    let client = get_client(&mock_server).await;

    let entry_id = "test-id-123";

    Mock::given(method("GET"))
        .and(path(format!("/api/v2/entries/sgv.json/{}", entry_id)))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ "sgv": 100, "date": 0, "dateString": "", "direction": "Flat", "type": "sgv" }])))
        .mount(&mock_server)
        .await;

    Mock::given(method("DELETE"))
        .and(path(format!("/api/v2/entries/sgv.json/{}", entry_id)))
        .respond_with(ResponseTemplate::new(200))
        .mount(&mock_server)
        .await;

    let result = client.sgv().delete().id(entry_id).send().await;
    assert!(result.is_ok());
}

#[tokio::test]
async fn test_treatments_create_and_read() {
    let mock_server = MockServer::start().await;
    let client = get_client(&mock_server).await;

    let mock_treatment = json!({
        "eventType": "Correction Bolus",
        "created_at": "2023-10-27T10:00:00Z",
        "insulin": 2.5,
        "enteredBy": "TestRunner"
    });

    Mock::given(method("POST"))
        .and(path("/api/v2/treatments.json"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!([mock_treatment])))
        .mount(&mock_server)
        .await;

    let treatment_obj: Treatment = serde_json::from_value(mock_treatment.clone()).unwrap();
    let created = client
        .treatments()
        .create(vec![treatment_obj])
        .await
        .expect("Failed to create treatment");
    assert_eq!(created[0].insulin, Some(2.5));

    Mock::given(method("GET"))
        .and(path("/api/v2/treatments.json"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!([mock_treatment])))
        .mount(&mock_server)
        .await;

    let fetched = client
        .treatments()
        .get()
        .send()
        .await
        .expect("Failed to get treatments");
    assert_eq!(fetched[0].event_type, "Correction Bolus");
}

#[tokio::test]
async fn test_properties_filter() {
    let mock_server = MockServer::start().await;
    let client = get_client(&mock_server).await;

    let mock_props = json!({
        "iob": {
            "iob": 1.25,
            "activity": 0.1,
            "source": "openaps",
            "display": "IOB: 1.25 U",
            "displayLine": "1.25 U"
        },
        "cob": {
            "cob": 30.0,
            "isDecaying": 1,
            "decayedBy": "test",
            "source": "openaps",
            "display": {},
            "displayLine": "30g"
        }
    });

    Mock::given(method("GET"))
        .and(path("/api/v2/properties/iob,cob"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_props))
        .mount(&mock_server)
        .await;

    let result = client
        .properties()
        .get()
        .only(&[PropertyType::Iob, PropertyType::Cob])
        .send()
        .await
        .expect("Failed to fetch properties");

    assert!(result.iob.is_some());
    assert!(result.cob.is_some());
    assert!(result.basal.is_none());
    assert_eq!(result.iob.unwrap().iob, 1.25);
}

#[tokio::test]
async fn test_devicestatus_custom_device() {
    let mock_server = MockServer::start().await;
    let client = get_client(&mock_server).await;

    let mock_ds = json!([{
        "device": "MyPump",
        "created_at": "2023-10-27T10:00:00Z",
        "pump": { "battery": { "percent": 50 } }
    }]);

    Mock::given(method("GET"))
        .and(path("/api/v2/devicestatus.json"))
        .and(query_param("find[device]", "MyPump"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_ds))
        .mount(&mock_server)
        .await;

    let result = client
        .devicestatus()
        .get()
        .device(Device::Custom("MyPump".to_string()))
        .send()
        .await
        .expect("Failed to fetch devicestatus");

    assert_eq!(result[0].device, Some("MyPump".to_string()));
}

#[tokio::test]
async fn test_query_builder_auto_device() {
    let mock_server = MockServer::start().await;
    let client = get_client(&mock_server).await;

    let probe_response = json!([{
        "_id": "probe1",
        "sgv": 100,
        "date": 1000,
        "dateString": "now",
        "direction": "Flat",
        "type": "sgv",
        "device": "FoundDeviceName"
    }]);

    Mock::given(method("GET"))
        .and(path("/api/v2/entries/sgv.json"))
        .and(query_param("count", "1"))
        .respond_with(ResponseTemplate::new(200).set_body_json(probe_response))
        .up_to_n_times(1)
        .mount(&mock_server)
        .await;

    let actual_response = json!([
        {
            "_id": "real1",
            "sgv": 110,
            "date": 2000,
            "dateString": "later",
            "direction": "Flat",
            "type": "sgv",
            "device": "FoundDeviceName"
        },
        {
            "_id": "real2",
            "sgv": 115,
            "date": 3000,
            "dateString": "later2",
            "direction": "Flat",
            "type": "sgv",
            "device": "FoundDeviceName"
        }
    ]);

    Mock::given(method("GET"))
        .and(path("/api/v2/entries/sgv.json"))
        .and(query_param("count", "10"))
        .and(query_param("find[device]", "FoundDeviceName"))
        .respond_with(ResponseTemplate::new(200).set_body_json(actual_response))
        .mount(&mock_server)
        .await;

    let result = client
        .sgv()
        .get()
        .device(Device::Auto)
        .limit(10)
        .send()
        .await
        .expect("Auto device fetch failed");

    assert_eq!(result.len(), 2);
    assert_eq!(result[0].device.as_deref(), Some("FoundDeviceName"));
}

#[tokio::test]
async fn test_mbg_latest() {
    let mock_server = MockServer::start().await;
    let client = get_client(&mock_server).await;

    let mock_mbg = json!([
        {
            "_id": "m1",
            "mbg": 105,
            "date": 1000,
            "dateString": "now",
            "type": "mbg",
            "device": "Contour"
        }
    ]);

    Mock::given(method("GET"))
        .and(path("/api/v2/entries/mbg.json"))
        .and(query_param("count", "1"))
        .respond_with(ResponseTemplate::new(200).set_body_json(mock_mbg))
        .mount(&mock_server)
        .await;

    let entry = client
        .mbg()
        .latest()
        .await
        .expect("Failed to fetch latest MBG");
    assert_eq!(entry.mbg, 105);
}