Skip to content

Commit 22b7a8b

Browse files
[codex] Harden threads schema parse contract tests (tinyhumansai#797)
Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
1 parent e86406e commit 22b7a8b

1 file changed

Lines changed: 172 additions & 0 deletions

File tree

‎src/openhuman/threads/schemas.rs‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ fn to_json<T: serde::Serialize>(outcome: crate::rpc::RpcOutcome<T>) -> Result<Va
346346
#[cfg(test)]
347347
mod tests {
348348
use super::*;
349+
use serde_json::json;
349350

350351
const ALL_FUNCTIONS: &[&str] = &[
351352
"list",
@@ -398,4 +399,175 @@ mod tests {
398399
assert_eq!(s.function, "unknown");
399400
assert_eq!(s.namespace, "threads");
400401
}
402+
403+
// ── parse::<T>(params) contract ─────────────────────────────────────
404+
405+
fn obj(value: Value) -> Map<String, Value> {
406+
match value {
407+
Value::Object(m) => m,
408+
_ => panic!("expected JSON object"),
409+
}
410+
}
411+
412+
#[test]
413+
fn parse_upsert_accepts_snake_case_contract() {
414+
let p: UpsertConversationThreadRequest = parse(obj(json!({
415+
"id": "t1",
416+
"title": "Hello",
417+
"created_at": "2026-04-22T00:00:00Z",
418+
})))
419+
.expect("valid snake_case params parse");
420+
assert_eq!(p.id, "t1");
421+
assert_eq!(p.title, "Hello");
422+
assert_eq!(p.created_at, "2026-04-22T00:00:00Z");
423+
}
424+
425+
#[test]
426+
fn parse_upsert_rejects_camel_case_created_at() {
427+
// Request params contract is snake_case; camelCase must not silently
428+
// succeed because `createdAt` leaves `created_at` missing and also
429+
// trips deny_unknown_fields.
430+
let err = parse::<UpsertConversationThreadRequest>(obj(json!({
431+
"id": "t1",
432+
"title": "Hello",
433+
"createdAt": "2026-04-22T00:00:00Z",
434+
})))
435+
.unwrap_err();
436+
assert!(err.starts_with("invalid params:"), "prefix: {err}");
437+
}
438+
439+
#[test]
440+
fn parse_upsert_rejects_unknown_fields() {
441+
let err = parse::<UpsertConversationThreadRequest>(obj(json!({
442+
"id": "t1",
443+
"title": "Hello",
444+
"created_at": "2026-04-22T00:00:00Z",
445+
"extra": "nope",
446+
})))
447+
.unwrap_err();
448+
assert!(err.starts_with("invalid params:"), "prefix: {err}");
449+
assert!(err.contains("extra"), "field name in error: {err}");
450+
}
451+
452+
#[test]
453+
fn parse_upsert_missing_required_field_errors() {
454+
let err = parse::<UpsertConversationThreadRequest>(obj(json!({
455+
"id": "t1",
456+
"title": "Hello",
457+
})))
458+
.unwrap_err();
459+
assert!(err.starts_with("invalid params:"), "prefix: {err}");
460+
assert!(err.contains("created_at"), "field name in error: {err}");
461+
}
462+
463+
#[test]
464+
fn parse_messages_list_requires_thread_id() {
465+
let ok: ConversationMessagesRequest = parse(obj(json!({"thread_id": "t1"}))).unwrap();
466+
assert_eq!(ok.thread_id, "t1");
467+
468+
let err = parse::<ConversationMessagesRequest>(obj(json!({}))).unwrap_err();
469+
assert!(err.contains("thread_id"), "err: {err}");
470+
471+
// camelCase alias is not accepted under deny_unknown_fields.
472+
let err = parse::<ConversationMessagesRequest>(obj(json!({"threadId": "t1"}))).unwrap_err();
473+
assert!(err.starts_with("invalid params:"), "prefix: {err}");
474+
}
475+
476+
#[test]
477+
fn parse_message_append_nested_message_requires_camel_case() {
478+
// Outer request is snake_case; nested ConversationMessageRecord is
479+
// camelCase by contract (messageType / createdAt). Assert both paths.
480+
let ok: AppendConversationMessageRequest = parse(obj(json!({
481+
"thread_id": "t1",
482+
"message": {
483+
"id": "m1",
484+
"content": "hi",
485+
"type": "text",
486+
"sender": "user",
487+
"createdAt": "2026-04-22T00:00:00Z",
488+
}
489+
})))
490+
.expect("valid nested camelCase message");
491+
assert_eq!(ok.thread_id, "t1");
492+
assert_eq!(ok.message.id, "m1");
493+
assert_eq!(ok.message.created_at, "2026-04-22T00:00:00Z");
494+
495+
let err = parse::<AppendConversationMessageRequest>(obj(json!({
496+
"thread_id": "t1",
497+
"message": {
498+
"id": "m1",
499+
"content": "hi",
500+
"type": "text",
501+
"sender": "user",
502+
"created_at": "2026-04-22T00:00:00Z",
503+
}
504+
})))
505+
.unwrap_err();
506+
assert!(
507+
err.contains("createdAt"),
508+
"err surfaces expected key: {err}"
509+
);
510+
}
511+
512+
#[test]
513+
fn parse_generate_title_assistant_message_is_optional() {
514+
let without: GenerateConversationThreadTitleRequest =
515+
parse(obj(json!({"thread_id": "t1"}))).unwrap();
516+
assert_eq!(without.thread_id, "t1");
517+
assert_eq!(without.assistant_message, None);
518+
519+
let with: GenerateConversationThreadTitleRequest = parse(obj(json!({
520+
"thread_id": "t1",
521+
"assistant_message": "reply",
522+
})))
523+
.unwrap();
524+
assert_eq!(with.assistant_message.as_deref(), Some("reply"));
525+
}
526+
527+
#[test]
528+
fn parse_message_update_extra_metadata_optional_and_unknown_rejected() {
529+
let without: UpdateConversationMessageRequest = parse(obj(json!({
530+
"thread_id": "t1",
531+
"message_id": "m1",
532+
})))
533+
.unwrap();
534+
assert!(without.extra_metadata.is_none());
535+
536+
let with: UpdateConversationMessageRequest = parse(obj(json!({
537+
"thread_id": "t1",
538+
"message_id": "m1",
539+
"extra_metadata": {"k": "v"},
540+
})))
541+
.unwrap();
542+
assert_eq!(with.extra_metadata, Some(json!({"k": "v"})));
543+
544+
let err = parse::<UpdateConversationMessageRequest>(obj(json!({
545+
"thread_id": "t1",
546+
"message_id": "m1",
547+
"bogus": true,
548+
})))
549+
.unwrap_err();
550+
assert!(err.contains("bogus"), "err: {err}");
551+
}
552+
553+
#[test]
554+
fn parse_delete_requires_thread_id_and_deleted_at() {
555+
let ok: DeleteConversationThreadRequest = parse(obj(json!({
556+
"thread_id": "t1",
557+
"deleted_at": "2026-04-22T00:00:00Z",
558+
})))
559+
.unwrap();
560+
assert_eq!(ok.thread_id, "t1");
561+
562+
let err =
563+
parse::<DeleteConversationThreadRequest>(obj(json!({"thread_id": "t1"}))).unwrap_err();
564+
assert!(err.contains("deleted_at"), "err: {err}");
565+
}
566+
567+
#[test]
568+
fn parse_empty_request_rejects_any_field() {
569+
let _: EmptyRequest = parse(obj(json!({}))).unwrap();
570+
let err = parse::<EmptyRequest>(obj(json!({"x": 1}))).unwrap_err();
571+
assert!(err.starts_with("invalid params:"), "prefix: {err}");
572+
}
401573
}

0 commit comments

Comments
 (0)