@@ -346,6 +346,7 @@ fn to_json<T: serde::Serialize>(outcome: crate::rpc::RpcOutcome<T>) -> Result<Va
346346#[ cfg( test) ]
347347mod 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