Skip to content

Commit 8ef518a

Browse files
committed
test(coverage): batch 11–12 — channels schemas, conversation store (tinyhumansai#530)
Add 33 new tests: - channels/controllers/schemas: per-function input validation, param deserialization, helper coverage - memory/conversations/store: thread lifecycle (create, delete, idempotent), multi-thread, empty/nonexistent thread, purge empty store All 4060 tests pass.
1 parent 2f033a7 commit 8ef518a

2 files changed

Lines changed: 370 additions & 0 deletions

File tree

‎src/openhuman/channels/controllers/schemas.rs‎

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,7 @@ fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String>
676676
#[cfg(test)]
677677
mod tests {
678678
use super::*;
679+
use serde_json::json;
679680

680681
#[test]
681682
fn schema_handler_parity() {
@@ -781,4 +782,229 @@ mod tests {
781782
// schema must still exist with outputs.
782783
assert!(!s.outputs.is_empty());
783784
}
785+
786+
#[test]
787+
fn connect_schema_requires_channel_auth_mode() {
788+
let s = schemas("connect");
789+
let required: Vec<&str> = s
790+
.inputs
791+
.iter()
792+
.filter(|f| f.required)
793+
.map(|f| f.name)
794+
.collect();
795+
assert!(required.contains(&"channel"));
796+
assert!(required.contains(&"authMode"));
797+
}
798+
799+
#[test]
800+
fn disconnect_schema_requires_channel_auth_mode() {
801+
let s = schemas("disconnect");
802+
let required: Vec<&str> = s
803+
.inputs
804+
.iter()
805+
.filter(|f| f.required)
806+
.map(|f| f.name)
807+
.collect();
808+
assert!(required.contains(&"channel"));
809+
assert!(required.contains(&"authMode"));
810+
}
811+
812+
#[test]
813+
fn status_schema_has_optional_channel() {
814+
let s = schemas("status");
815+
let chan = s.inputs.iter().find(|f| f.name == "channel");
816+
assert!(chan.is_some_and(|f| !f.required));
817+
}
818+
819+
#[test]
820+
fn test_schema_requires_channel_auth_mode_credentials() {
821+
let s = schemas("test");
822+
let required: Vec<&str> = s
823+
.inputs
824+
.iter()
825+
.filter(|f| f.required)
826+
.map(|f| f.name)
827+
.collect();
828+
assert!(required.contains(&"channel"));
829+
assert!(required.contains(&"authMode"));
830+
assert!(required.contains(&"credentials"));
831+
}
832+
833+
#[test]
834+
fn list_schema_has_no_inputs() {
835+
let s = schemas("list");
836+
assert!(s.inputs.is_empty());
837+
}
838+
839+
#[test]
840+
fn discord_link_start_schema() {
841+
let s = schemas("discord_link_start");
842+
assert_eq!(s.namespace, "channels");
843+
assert_eq!(s.function, "discord_link_start");
844+
}
845+
846+
#[test]
847+
fn discord_link_check_requires_link_token() {
848+
let s = schemas("discord_link_check");
849+
let required: Vec<&str> = s
850+
.inputs
851+
.iter()
852+
.filter(|f| f.required)
853+
.map(|f| f.name)
854+
.collect();
855+
assert!(required.contains(&"linkToken"));
856+
}
857+
858+
#[test]
859+
fn discord_list_channels_requires_guild_id() {
860+
let s = schemas("discord_list_channels");
861+
let required: Vec<&str> = s
862+
.inputs
863+
.iter()
864+
.filter(|f| f.required)
865+
.map(|f| f.name)
866+
.collect();
867+
assert!(required.contains(&"guildId"));
868+
}
869+
870+
#[test]
871+
fn discord_check_permissions_requires_guild_and_channel() {
872+
let s = schemas("discord_check_permissions");
873+
let required: Vec<&str> = s
874+
.inputs
875+
.iter()
876+
.filter(|f| f.required)
877+
.map(|f| f.name)
878+
.collect();
879+
assert!(required.contains(&"guildId"));
880+
assert!(required.contains(&"channelId"));
881+
}
882+
883+
#[test]
884+
fn send_reaction_requires_channel_and_reaction() {
885+
let s = schemas("send_reaction");
886+
let required: Vec<&str> = s
887+
.inputs
888+
.iter()
889+
.filter(|f| f.required)
890+
.map(|f| f.name)
891+
.collect();
892+
assert!(required.contains(&"channel"));
893+
assert!(required.contains(&"reaction"));
894+
}
895+
896+
#[test]
897+
fn create_thread_requires_channel_and_title() {
898+
let s = schemas("create_thread");
899+
let required: Vec<&str> = s
900+
.inputs
901+
.iter()
902+
.filter(|f| f.required)
903+
.map(|f| f.name)
904+
.collect();
905+
assert!(required.contains(&"channel"));
906+
assert!(required.contains(&"title"));
907+
}
908+
909+
#[test]
910+
fn update_thread_requires_channel_thread_id_action() {
911+
let s = schemas("update_thread");
912+
let required: Vec<&str> = s
913+
.inputs
914+
.iter()
915+
.filter(|f| f.required)
916+
.map(|f| f.name)
917+
.collect();
918+
assert!(required.contains(&"channel"));
919+
assert!(required.contains(&"threadId"));
920+
assert!(required.contains(&"action"));
921+
}
922+
923+
#[test]
924+
fn list_threads_requires_channel() {
925+
let s = schemas("list_threads");
926+
let required: Vec<&str> = s
927+
.inputs
928+
.iter()
929+
.filter(|f| f.required)
930+
.map(|f| f.name)
931+
.collect();
932+
assert!(required.contains(&"channel"));
933+
}
934+
935+
#[test]
936+
fn telegram_login_start_schema_has_no_inputs() {
937+
let s = schemas("telegram_login_start");
938+
assert!(s.inputs.is_empty());
939+
}
940+
941+
#[test]
942+
fn deserialize_connect_params() {
943+
let params: ConnectParams = serde_json::from_value(json!({
944+
"channel": "telegram",
945+
"authMode": "bot_token"
946+
}))
947+
.unwrap();
948+
assert_eq!(params.channel, "telegram");
949+
assert_eq!(params.auth_mode, "bot_token");
950+
assert!(params.credentials.is_none());
951+
}
952+
953+
#[test]
954+
fn deserialize_disconnect_params() {
955+
let params: DisconnectParams = serde_json::from_value(json!({
956+
"channel": "discord",
957+
"authMode": "bot_token"
958+
}))
959+
.unwrap();
960+
assert_eq!(params.channel, "discord");
961+
}
962+
963+
#[test]
964+
fn deserialize_status_params_empty() {
965+
let params: StatusParams = serde_json::from_value(json!({})).unwrap();
966+
assert!(params.channel.is_none());
967+
}
968+
969+
#[test]
970+
fn deserialize_status_params_with_channel() {
971+
let params: StatusParams = serde_json::from_value(json!({"channel": "telegram"})).unwrap();
972+
assert_eq!(params.channel.as_deref(), Some("telegram"));
973+
}
974+
975+
#[test]
976+
fn deserialize_send_message_params() {
977+
let params: SendMessageParams = serde_json::from_value(json!({
978+
"channel": "telegram",
979+
"message": {"text": "hello"}
980+
}))
981+
.unwrap();
982+
assert_eq!(params.channel, "telegram");
983+
}
984+
985+
#[test]
986+
fn to_json_helper() {
987+
let outcome = RpcOutcome::single_log(json!({"ok": true}), "log");
988+
assert!(to_json(outcome).is_ok());
989+
}
990+
991+
#[test]
992+
fn required_string_helper() {
993+
let f = required_string("channel", "channel name");
994+
assert!(f.required);
995+
assert!(matches!(f.ty, TypeSchema::String));
996+
}
997+
998+
#[test]
999+
fn optional_string_helper() {
1000+
let f = optional_string("auth_mode", "auth");
1001+
assert!(!f.required);
1002+
}
1003+
1004+
#[test]
1005+
fn json_output_helper() {
1006+
let f = json_output("result", "the result");
1007+
assert!(f.required);
1008+
assert!(matches!(f.ty, TypeSchema::Json));
1009+
}
7841010
}

‎src/openhuman/memory/conversations/store.rs‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,4 +554,148 @@ mod tests {
554554
assert_eq!(stats.message_count, 1);
555555
assert!(store.list_threads().expect("list threads").is_empty());
556556
}
557+
558+
#[test]
559+
fn ensure_thread_is_idempotent() {
560+
let (_temp, store) = make_store();
561+
let req = CreateConversationThread {
562+
id: "t1".to_string(),
563+
title: "Thread".to_string(),
564+
created_at: "2026-04-10T12:00:00Z".to_string(),
565+
};
566+
store.ensure_thread(req.clone()).unwrap();
567+
store.ensure_thread(req).unwrap();
568+
let threads = store.list_threads().unwrap();
569+
assert_eq!(threads.len(), 1);
570+
}
571+
572+
#[test]
573+
fn delete_thread_removes_thread_and_messages() {
574+
let (_temp, store) = make_store();
575+
store
576+
.ensure_thread(CreateConversationThread {
577+
id: "t1".to_string(),
578+
title: "Thread".to_string(),
579+
created_at: "2026-04-10T12:00:00Z".to_string(),
580+
})
581+
.unwrap();
582+
store
583+
.append_message(
584+
"t1",
585+
ConversationMessage {
586+
id: "m1".to_string(),
587+
content: "msg".to_string(),
588+
message_type: "text".to_string(),
589+
extra_metadata: json!({}),
590+
sender: "user".to_string(),
591+
created_at: "2026-04-10T12:01:00Z".to_string(),
592+
},
593+
)
594+
.unwrap();
595+
store.delete_thread("t1", "2026-04-10T12:02:00Z").unwrap();
596+
let threads = store.list_threads().unwrap();
597+
assert!(threads.is_empty());
598+
}
599+
600+
#[test]
601+
fn delete_nonexistent_thread_is_ok() {
602+
let (_temp, store) = make_store();
603+
// Should not error
604+
store
605+
.delete_thread("nonexistent", "2026-04-10T12:00:00Z")
606+
.unwrap();
607+
}
608+
609+
#[test]
610+
fn get_messages_empty_thread() {
611+
let (_temp, store) = make_store();
612+
store
613+
.ensure_thread(CreateConversationThread {
614+
id: "t1".to_string(),
615+
title: "Empty".to_string(),
616+
created_at: "2026-04-10T12:00:00Z".to_string(),
617+
})
618+
.unwrap();
619+
let messages = store.get_messages("t1").unwrap();
620+
assert!(messages.is_empty());
621+
}
622+
623+
#[test]
624+
fn get_messages_nonexistent_thread() {
625+
let (_temp, store) = make_store();
626+
let messages = store.get_messages("nonexistent").unwrap();
627+
assert!(messages.is_empty());
628+
}
629+
630+
#[test]
631+
fn multiple_threads_and_messages() {
632+
let (_temp, store) = make_store();
633+
for i in 0..3 {
634+
store
635+
.ensure_thread(CreateConversationThread {
636+
id: format!("t{i}"),
637+
title: format!("Thread {i}"),
638+
created_at: format!("2026-04-10T12:0{i}:00Z"),
639+
})
640+
.unwrap();
641+
store
642+
.append_message(
643+
&format!("t{i}"),
644+
ConversationMessage {
645+
id: format!("m{i}"),
646+
content: format!("msg {i}"),
647+
message_type: "text".to_string(),
648+
extra_metadata: json!({}),
649+
sender: "user".to_string(),
650+
created_at: format!("2026-04-10T12:0{i}:30Z"),
651+
},
652+
)
653+
.unwrap();
654+
}
655+
let threads = store.list_threads().unwrap();
656+
assert_eq!(threads.len(), 3);
657+
}
658+
659+
#[test]
660+
fn purge_on_empty_store() {
661+
let (_temp, store) = make_store();
662+
let stats = store.purge_threads().unwrap();
663+
assert_eq!(stats.thread_count, 0);
664+
assert_eq!(stats.message_count, 0);
665+
}
666+
667+
#[test]
668+
fn update_message_nonexistent_returns_error() {
669+
let (_temp, store) = make_store();
670+
store
671+
.ensure_thread(CreateConversationThread {
672+
id: "t1".to_string(),
673+
title: "Thread".to_string(),
674+
created_at: "2026-04-10T12:00:00Z".to_string(),
675+
})
676+
.unwrap();
677+
let result = store.update_message(
678+
"t1",
679+
"nonexistent",
680+
ConversationMessagePatch {
681+
extra_metadata: Some(json!({})),
682+
},
683+
);
684+
assert!(result.is_err());
685+
}
686+
687+
#[test]
688+
fn conversation_store_new() {
689+
let tmp = TempDir::new().unwrap();
690+
let store = ConversationStore::new(tmp.path().to_path_buf());
691+
let threads = store.list_threads().unwrap();
692+
assert!(threads.is_empty());
693+
}
694+
695+
#[test]
696+
fn conversation_purge_stats_default() {
697+
let stats = ConversationPurgeStats::default();
698+
assert_eq!(stats.thread_count, 0);
699+
assert_eq!(stats.message_count, 0);
700+
}
557701
}

0 commit comments

Comments
 (0)