diff --git a/src/openhuman/threads/schemas.rs b/src/openhuman/threads/schemas.rs index 0e64acabb..9af6cf3cb 100644 --- a/src/openhuman/threads/schemas.rs +++ b/src/openhuman/threads/schemas.rs @@ -346,6 +346,7 @@ fn to_json(outcome: crate::rpc::RpcOutcome) -> Result(params) contract ───────────────────────────────────── + + fn obj(value: Value) -> Map { + match value { + Value::Object(m) => m, + _ => panic!("expected JSON object"), + } + } + + #[test] + fn parse_upsert_accepts_snake_case_contract() { + let p: UpsertConversationThreadRequest = parse(obj(json!({ + "id": "t1", + "title": "Hello", + "created_at": "2026-04-22T00:00:00Z", + }))) + .expect("valid snake_case params parse"); + assert_eq!(p.id, "t1"); + assert_eq!(p.title, "Hello"); + assert_eq!(p.created_at, "2026-04-22T00:00:00Z"); + } + + #[test] + fn parse_upsert_rejects_camel_case_created_at() { + // Request params contract is snake_case; camelCase must not silently + // succeed because `createdAt` leaves `created_at` missing and also + // trips deny_unknown_fields. + let err = parse::(obj(json!({ + "id": "t1", + "title": "Hello", + "createdAt": "2026-04-22T00:00:00Z", + }))) + .unwrap_err(); + assert!(err.starts_with("invalid params:"), "prefix: {err}"); + } + + #[test] + fn parse_upsert_rejects_unknown_fields() { + let err = parse::(obj(json!({ + "id": "t1", + "title": "Hello", + "created_at": "2026-04-22T00:00:00Z", + "extra": "nope", + }))) + .unwrap_err(); + assert!(err.starts_with("invalid params:"), "prefix: {err}"); + assert!(err.contains("extra"), "field name in error: {err}"); + } + + #[test] + fn parse_upsert_missing_required_field_errors() { + let err = parse::(obj(json!({ + "id": "t1", + "title": "Hello", + }))) + .unwrap_err(); + assert!(err.starts_with("invalid params:"), "prefix: {err}"); + assert!(err.contains("created_at"), "field name in error: {err}"); + } + + #[test] + fn parse_messages_list_requires_thread_id() { + let ok: ConversationMessagesRequest = parse(obj(json!({"thread_id": "t1"}))).unwrap(); + assert_eq!(ok.thread_id, "t1"); + + let err = parse::(obj(json!({}))).unwrap_err(); + assert!(err.contains("thread_id"), "err: {err}"); + + // camelCase alias is not accepted under deny_unknown_fields. + let err = parse::(obj(json!({"threadId": "t1"}))).unwrap_err(); + assert!(err.starts_with("invalid params:"), "prefix: {err}"); + } + + #[test] + fn parse_message_append_nested_message_requires_camel_case() { + // Outer request is snake_case; nested ConversationMessageRecord is + // camelCase by contract (messageType / createdAt). Assert both paths. + let ok: AppendConversationMessageRequest = parse(obj(json!({ + "thread_id": "t1", + "message": { + "id": "m1", + "content": "hi", + "type": "text", + "sender": "user", + "createdAt": "2026-04-22T00:00:00Z", + } + }))) + .expect("valid nested camelCase message"); + assert_eq!(ok.thread_id, "t1"); + assert_eq!(ok.message.id, "m1"); + assert_eq!(ok.message.created_at, "2026-04-22T00:00:00Z"); + + let err = parse::(obj(json!({ + "thread_id": "t1", + "message": { + "id": "m1", + "content": "hi", + "type": "text", + "sender": "user", + "created_at": "2026-04-22T00:00:00Z", + } + }))) + .unwrap_err(); + assert!( + err.contains("createdAt"), + "err surfaces expected key: {err}" + ); + } + + #[test] + fn parse_generate_title_assistant_message_is_optional() { + let without: GenerateConversationThreadTitleRequest = + parse(obj(json!({"thread_id": "t1"}))).unwrap(); + assert_eq!(without.thread_id, "t1"); + assert_eq!(without.assistant_message, None); + + let with: GenerateConversationThreadTitleRequest = parse(obj(json!({ + "thread_id": "t1", + "assistant_message": "reply", + }))) + .unwrap(); + assert_eq!(with.assistant_message.as_deref(), Some("reply")); + } + + #[test] + fn parse_message_update_extra_metadata_optional_and_unknown_rejected() { + let without: UpdateConversationMessageRequest = parse(obj(json!({ + "thread_id": "t1", + "message_id": "m1", + }))) + .unwrap(); + assert!(without.extra_metadata.is_none()); + + let with: UpdateConversationMessageRequest = parse(obj(json!({ + "thread_id": "t1", + "message_id": "m1", + "extra_metadata": {"k": "v"}, + }))) + .unwrap(); + assert_eq!(with.extra_metadata, Some(json!({"k": "v"}))); + + let err = parse::(obj(json!({ + "thread_id": "t1", + "message_id": "m1", + "bogus": true, + }))) + .unwrap_err(); + assert!(err.contains("bogus"), "err: {err}"); + } + + #[test] + fn parse_delete_requires_thread_id_and_deleted_at() { + let ok: DeleteConversationThreadRequest = parse(obj(json!({ + "thread_id": "t1", + "deleted_at": "2026-04-22T00:00:00Z", + }))) + .unwrap(); + assert_eq!(ok.thread_id, "t1"); + + let err = + parse::(obj(json!({"thread_id": "t1"}))).unwrap_err(); + assert!(err.contains("deleted_at"), "err: {err}"); + } + + #[test] + fn parse_empty_request_rejects_any_field() { + let _: EmptyRequest = parse(obj(json!({}))).unwrap(); + let err = parse::(obj(json!({"x": 1}))).unwrap_err(); + assert!(err.starts_with("invalid params:"), "prefix: {err}"); + } }