things3-cloud 0.8.0

Command-line client for Things 3 using the Things Cloud API
Documentation
#[cfg(test)]
mod tests {
    use crate::{
        ids::ThingsId,
        wire::{
            checklist::ChecklistItemProps,
            recurrence::{FrequencyUnit, RecurrenceRule},
            tags::TagPatch,
            task::{TaskPatch, TaskProps, TaskStart, TaskStatus},
            wire_object::{EntityType, OperationType, Properties, WireItem, WireObject},
        },
    };

    fn id(s: &str) -> ThingsId {
        s.parse::<ThingsId>()
            .expect("test id should parse as ThingsId")
    }

    const ID_A: &str = "A7h5eCi24RvAWKC3Hv3muf";
    const ID_B: &str = "MpkEei6ybkFS2n6SXvwfLf";
    const ID_C: &str = "JFdhhhp37fpryAKu8UXwzK";

    #[test]
    fn wire_object_deserializes_with_wire_keys() {
        let json = r#"{
            "abc-123": {
                "t": 1,
                "e": "Task6",
                "p": {"tt": "Title", "ss": 0}
            }
        }"#;

        let item: WireItem = serde_json::from_str(json).expect("valid wire item");
        let object = item.get("abc-123").expect("object exists");

        assert_eq!(object.operation_type, OperationType::Update);
        assert_eq!(object.entity_type, Some(EntityType::Task6));
        assert_eq!(
            object
                .properties_map()
                .get("tt")
                .and_then(|v| v.as_str())
                .map(str::to_string)
                .as_deref(),
            Some("Title")
        );
    }

    #[test]
    fn task_props_maps_readable_fields_to_wire_names() {
        let props = TaskProps {
            title: "Ship v1".to_string(),
            status: TaskStatus::Completed,
            start_location: TaskStart::Anytime,
            parent_project_ids: vec![id(ID_A)],
            area_ids: vec![id(ID_B)],
            tag_ids: vec![id(ID_C)],
            evening_bit: 1,
            ..TaskProps::default()
        };

        let encoded = serde_json::to_value(props).expect("serialize task props");

        assert_eq!(encoded.get("tt").and_then(|v| v.as_str()), Some("Ship v1"));
        assert_eq!(encoded.get("ss").and_then(|v| v.as_i64()), Some(3));
        assert_eq!(encoded.get("st").and_then(|v| v.as_i64()), Some(1));
        assert_eq!(encoded.get("sb").and_then(|v| v.as_i64()), Some(1));
        assert!(encoded.get("title").is_none());
        assert!(encoded.get("status").is_none());
    }

    #[test]
    fn task_props_accepts_null_for_defaulted_scalar_fields() {
        let json = r#"{
            "tt": "Personal Website",
            "tp": 1,
            "ss": 0,
            "st": 1,
            "pr": [],
            "ar": [],
            "agr": [],
            "tg": [],
            "ix": -7069,
            "ti": null,
            "do": null,
            "rt": [],
            "icc": null,
            "icp": true,
            "sb": null,
            "lt": null,
            "tr": false,
            "dl": []
        }"#;

        let parsed: TaskProps = serde_json::from_str(json).expect("valid task props with nulls");
        assert_eq!(parsed.today_sort_index, 0);
        assert_eq!(parsed.due_date_offset, 0);
        assert_eq!(parsed.checklist_item_count, 0);
        assert_eq!(parsed.evening_bit, 0);
        assert!(!parsed.leaves_tombstone);
    }

    #[test]
    fn checklist_item_accepts_task_ids_list_wire_shape() {
        let json = r#"{
            "tt": "One",
            "ss": 0,
            "ts": ["A7h5eCi24RvAWKC3Hv3muf"],
            "ix": 9
        }"#;

        let parsed: ChecklistItemProps =
            serde_json::from_str(json).expect("valid checklist item props");
        assert_eq!(parsed.title, "One");
        assert_eq!(parsed.task_ids, vec![id(ID_A)]);
        assert_eq!(parsed.sort_index, 9);
    }

    #[test]
    fn checklist_item_accepts_single_task_id_wire_shape() {
        let json = r#"{
            "tt": "One",
            "ss": 0,
            "ts": "A7h5eCi24RvAWKC3Hv3muf",
            "ix": 9
        }"#;

        let parsed: ChecklistItemProps =
            serde_json::from_str(json).expect("valid checklist item props");
        assert_eq!(parsed.title, "One");
        assert_eq!(parsed.task_ids, vec![id(ID_A)]);
        assert_eq!(parsed.sort_index, 9);
    }

    #[test]
    fn checklist_item_accepts_null_lt_as_default_false() {
        let json = r#"{
            "tt": "One",
            "ss": 0,
            "ts": ["A7h5eCi24RvAWKC3Hv3muf"],
            "ix": 9,
            "lt": null
        }"#;

        let parsed: ChecklistItemProps =
            serde_json::from_str(json).expect("valid checklist item props with null lt");
        assert!(!parsed.leaves_tombstone);
    }

    #[test]
    fn checklist_item_create_omits_unset_optional_fields() {
        let props = ChecklistItemProps {
            title: "One".to_string(),
            status: TaskStatus::Incomplete,
            task_ids: vec![id(ID_A)],
            sort_index: 9,
            creation_date: Some(1.0),
            modification_date: Some(2.0),
            ..ChecklistItemProps::default()
        };

        let encoded = serde_json::to_value(props).expect("serialize checklist props");

        assert_eq!(encoded.get("tt").and_then(|v| v.as_str()), Some("One"));
        assert_eq!(encoded.get("ss").and_then(|v| v.as_i64()), Some(0));
        assert_eq!(encoded.get("ix").and_then(|v| v.as_i64()), Some(9));
        assert!(encoded.get("sp").is_none());
        assert!(encoded.get("lt").is_none());
        assert!(encoded.get("xx").is_none());
    }

    #[test]
    fn recurrence_rule_defaults_match_protocol() {
        let parsed: RecurrenceRule = serde_json::from_str("{}")
            .expect("empty recurrence should deserialize with protocol defaults");

        assert_eq!(parsed.frequency_unit, FrequencyUnit::Weekly);
        assert_eq!(parsed.frequency_amount, 1);
        assert_eq!(parsed.end_date, 64_092_211_200);
        assert_eq!(parsed.recurrence_rule_version, 4);
    }

    #[test]
    fn operation_enum_serializes_to_wire_integer() {
        let object = WireObject {
            operation_type: OperationType::Delete,
            entity_type: None,
            payload: Properties::Delete,
        };
        let json = serde_json::to_string(&object).expect("serialize wire object");
        assert!(json.contains("\"t\":2"));
    }

    #[test]
    fn unknown_numeric_enum_values_round_trip() {
        let parsed: WireObject = serde_json::from_str(r#"{"t":99,"e":"Task6","p":{}}"#)
            .expect("deserialize with unknown op type");

        assert_eq!(parsed.operation_type, OperationType::Unknown(99));

        let json = serde_json::to_string(&parsed).expect("serialize unknown op type");
        assert!(json.contains("\"t\":99"));
    }

    #[test]
    fn unknown_entity_values_round_trip() {
        let parsed: WireObject =
            serde_json::from_str(r#"{"t":1,"e":"Task7","p":{}}"#).expect("deserialize");
        assert_eq!(
            parsed.entity_type,
            Some(EntityType::Unknown("Task7".to_string()))
        );

        let json = serde_json::to_string(&parsed).expect("serialize unknown entity");
        assert!(json.contains("\"e\":\"Task7\""));
    }

    #[test]
    fn typed_properties_dispatch_for_task_create() {
        let parsed: WireObject =
            serde_json::from_str(r#"{"t":0,"e":"Task6","p":{"tt":"A","ss":0,"tp":0,"st":0}}"#)
                .expect("deserialize");

        let typed = parsed.properties().expect("typed properties");
        match typed {
            Properties::TaskCreate(props) => {
                assert_eq!(props.title, "A");
                assert_eq!(props.status, TaskStatus::Incomplete);
            }
            other => panic!("unexpected variant: {other:?}"),
        }
    }

    #[test]
    fn typed_properties_dispatch_for_delete() {
        let parsed: WireObject =
            serde_json::from_str(r#"{"t":2,"e":"Task6","p":{}}"#).expect("deserialize");
        let typed = parsed.properties().expect("typed properties");
        assert!(matches!(typed, Properties::Delete));
    }

    #[test]
    fn task_patch_preserves_explicit_null_for_clearable_fields() {
        let patch: TaskPatch = serde_json::from_str(r#"{"sr":null,"tir":null,"sp":null}"#)
            .expect("deserialize patch with nulls");

        assert_eq!(patch.scheduled_date, Some(None));
        assert_eq!(patch.today_index_reference, Some(None));
        assert_eq!(patch.stop_date, Some(None));
    }

    #[test]
    fn task_update_wire_object_keeps_null_clears() {
        let parsed: WireObject =
            serde_json::from_str(r#"{"t":1,"e":"Task6","p":{"sr":null,"tir":null}}"#)
                .expect("deserialize wire update");

        let typed = parsed.properties().expect("typed properties");
        match typed {
            Properties::TaskUpdate(patch) => {
                assert_eq!(patch.scheduled_date, Some(None));
                assert_eq!(patch.today_index_reference, Some(None));
            }
            other => panic!("unexpected variant: {other:?}"),
        }
    }

    #[test]
    fn tag_patch_preserves_explicit_null_for_shortcut() {
        let patch: TagPatch =
            serde_json::from_str(r#"{"sh":null}"#).expect("deserialize tag patch with null");
        assert_eq!(patch.shortcut, Some(None));
    }

    #[test]
    fn tag_update_wire_object_keeps_null_shortcut_clear() {
        let parsed: WireObject = serde_json::from_str(r#"{"t":1,"e":"Tag4","p":{"sh":null}}"#)
            .expect("deserialize tag wire update");

        let typed = parsed.properties().expect("typed properties");
        match typed {
            Properties::TagUpdate(patch) => {
                assert_eq!(patch.shortcut, Some(None));
            }
            other => panic!("unexpected variant: {other:?}"),
        }
    }
}