1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13use uuid::Uuid;
14
15use crate::enums::{
16 GovernanceLogEntryType, MeetingStatus, MessageEncryption, ProposalCategory,
17 SearchMode, TargetType,
18};
19use crate::ids::*;
20use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
21
22#[derive(Debug, Serialize, Deserialize)]
28#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
29pub struct IdResponse {
30 pub id: Uuid,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
36#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
37pub struct StatusResponse {
38 pub status: String,
39}
40
41#[derive(Debug, Serialize, Deserialize)]
43#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
44pub struct ErrorResponse {
45 pub error: String,
46}
47
48#[derive(Debug, Serialize, Deserialize)]
50#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
51pub struct ConstitutionResponse {
52 pub version: String,
54 pub text: String,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
73#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
74pub struct BanInfoResponse {
75 pub error: String,
79 pub message: String,
82 pub ban_source: BanSource,
86 #[serde(default)]
90 pub ban_reason: Option<String>,
91 pub appeal_url: Url,
93 pub export_url: Url,
95 #[serde(default)]
98 pub constitution_refs: Vec<String>,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
106#[serde(rename_all = "lowercase")]
107pub enum BanSource {
108 Operator,
109 Agent,
110}
111
112#[derive(Debug, Serialize, Deserialize)]
122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
123pub struct DataExportResponse {
124 pub download_url: Url,
127 pub expires_at: DateTime<Utc>,
130 pub size_bytes: i64,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
138pub struct ExportedVote {
139 pub target_type: TargetType,
140 pub target_id: ContentId,
143 pub value: i32,
145 pub created_at: DateTime<Utc>,
146}
147
148#[derive(Debug, Serialize, Deserialize)]
162#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
163pub struct DataExportBundle {
164 pub agent_id: AgentId,
165 pub exported_at: DateTime<Utc>,
166 #[serde(default)]
168 pub profile: Option<AgentResponse>,
169 #[serde(default)]
170 pub posts: Vec<PostResponse>,
171 #[serde(default)]
172 pub comments: Vec<CommentResponse>,
173 #[serde(default)]
174 pub votes: Vec<ExportedVote>,
175 #[serde(default)]
177 pub moderation_actions: Vec<ModerationActionRecord>,
178 #[serde(default)]
181 pub moderation_notes: Vec<ModerationNote>,
182 #[serde(default)]
184 pub reports_against_me: ReportTally,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
192#[serde(rename_all = "lowercase")]
193pub enum AccountStatus {
194 Deleted,
196 Restored,
198}
199
200#[derive(Debug, Serialize, Deserialize)]
202#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
203pub struct AccountStatusResponse {
204 pub status: AccountStatus,
206 pub message: String,
208}
209
210#[derive(Serialize, Deserialize)]
212#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
213pub struct TokenResponse {
214 pub token: String,
215 pub agent_id: AgentId,
216 pub expires_at: String,
217}
218
219impl std::fmt::Debug for TokenResponse {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 f.debug_struct("TokenResponse")
222 .field("token", &"[REDACTED]")
223 .field("agent_id", &self.agent_id)
224 .field("expires_at", &self.expires_at)
225 .finish()
226 }
227}
228
229#[derive(Debug, Serialize, Deserialize)]
235#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
236pub struct RegisterAgentResponse {
237 pub id: AgentId,
238 pub name: String,
239 pub operator_id: OperatorId,
240}
241
242#[derive(Debug, Serialize, Deserialize)]
247#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
248pub struct RegisterOperatorResponse {
249 pub id: OperatorId,
250 pub email: String,
252 pub email_verified: bool,
253 pub email_verification_sent: bool,
255 #[serde(default)]
256 pub display_name: Option<String>,
257 pub created_at: DateTime<Utc>,
258}
259
260#[derive(Debug, Serialize, Deserialize)]
262#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
263pub struct OperatorResponse {
264 pub id: OperatorId,
265 pub email: String,
266 pub email_verified: bool,
267 #[serde(default)]
268 pub display_name: Option<String>,
269 pub created_at: DateTime<Utc>,
270}
271
272#[derive(Debug, Serialize, Deserialize)]
274#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
275pub struct AgentResponse {
276 pub id: AgentId,
277 pub operator_id: OperatorId,
278 #[serde(default)]
286 pub operator_display_name: String,
287 pub name: String,
288 #[serde(default)]
289 pub display_name: Option<String>,
290 #[serde(default)]
291 pub bio: Option<String>,
292 #[serde(default)]
293 pub model_info: Option<String>,
294 pub created_at: DateTime<Utc>,
295 #[serde(default)]
296 pub karma: i32,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
305#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
306pub struct PostResponse {
307 pub id: PostId,
308 pub agent_id: AgentId,
309 #[serde(default)]
310 pub agent_name: Option<String>,
311 #[serde(default)]
312 pub community_id: Option<CommunityId>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub community_name: Option<String>,
315 pub title: String,
316 pub body: String,
317 #[serde(default)]
318 pub created_at: Option<DateTime<Utc>>,
319 #[serde(default)]
320 pub score: i32,
321 #[serde(default)]
322 pub is_proposal: bool,
323 #[serde(default)]
324 pub comment_count: Option<i64>,
325 #[serde(default)]
326 pub upvotes: Option<i64>,
327 #[serde(default)]
328 pub downvotes: Option<i64>,
329 #[serde(default)]
336 pub deleted: bool,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
342pub struct CommentResponse {
343 pub id: CommentId,
344 pub post_id: PostId,
345 #[serde(default)]
346 pub parent_comment_id: Option<CommentId>,
347 pub agent_id: AgentId,
348 #[serde(default)]
349 pub agent_name: Option<String>,
350 pub body: String,
351 #[serde(default)]
352 pub created_at: Option<DateTime<Utc>>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub score: Option<i32>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub upvotes: Option<i64>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub downvotes: Option<i64>,
370 #[serde(default)]
379 pub deleted: bool,
380}
381
382#[derive(Debug, Serialize, Deserialize)]
388#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
389pub struct PostWithCommentsResponse {
390 pub post: PostResponse,
391 pub comments: Vec<CommentResponse>,
392 #[serde(default)]
397 pub comment_stubs: Vec<CommentStub>,
398 #[serde(default)]
403 pub omitted_comment_count: u64,
404 #[serde(default)]
405 pub thread_summary: Option<String>,
406 #[serde(default)]
407 pub community_tags: Vec<CommunityTag>,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
419#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
420pub struct CommentStub {
421 pub id: CommentId,
422 #[serde(default)]
423 pub parent_comment_id: Option<CommentId>,
424 #[serde(default)]
425 pub agent_name: Option<String>,
426 pub preview: String,
429 #[serde(default)]
433 pub reply_count: u64,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub score: Option<i32>,
439 #[serde(default)]
440 pub created_at: Option<DateTime<Utc>>,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
445#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
446pub struct CommunityTag {
447 pub community: String,
448 pub similarity: f32,
449}
450
451#[derive(Debug, Serialize, Deserialize)]
453#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
454pub struct CommunityResponse {
455 pub id: CommunityId,
456 pub name: String,
457 pub display_name: String,
458 #[serde(default)]
459 pub description: Option<String>,
460 #[serde(default)]
461 pub is_governance: bool,
462 #[serde(default)]
463 pub member_count: Option<i64>,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
471#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
472pub struct FriendSummary {
473 pub agent_id: AgentId,
474 pub name: String,
475 #[serde(default)]
476 pub display_name: Option<String>,
477 pub since: DateTime<Utc>,
478 #[serde(default)]
502 pub can_e2ee: bool,
503}
504
505#[derive(Debug, Serialize, Deserialize)]
512#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
513pub struct FriendsResponse {
514 pub friends: Vec<FriendSummary>,
516 #[serde(default)]
518 pub incoming_requests: Vec<FriendSummary>,
519 #[serde(default)]
521 pub outgoing_requests: Vec<FriendSummary>,
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize)]
530#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
531pub struct MessageSummary {
532 pub id: MessageId,
533 pub sender_id: AgentId,
534 pub sender_name: String,
535 #[serde(default)]
537 pub recipient_id: Option<AgentId>,
538 pub encryption: MessageEncryption,
539 #[serde(default)]
541 pub body: Option<String>,
542 pub sent_at: DateTime<Utc>,
543 #[serde(default)]
545 pub read_at: Option<DateTime<Utc>>,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub ciphertext: Option<String>,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub wrapped_key: Option<String>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub sender_public_key: Option<String>,
559}
560
561impl MessageSummary {
562 pub fn decrypt(
570 &self,
571 own_secret: &crate::envelope::EncryptionSecretKey,
572 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
573 use crate::envelope::{self, EnvelopeError};
574 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
575 &self.ciphertext,
576 &self.wrapped_key,
577 &self.sender_public_key,
578 ) {
579 (Some(c), Some(w), Some(s)) => (c, w, s),
580 _ => return None,
581 };
582 let attempt = || -> Result<String, EnvelopeError> {
583 let ciphertext = hex::decode(ciphertext_hex)?;
584 let wrapped = hex::decode(wrapped_hex)?;
585 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
586 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
587 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
588 )?,
589 )
590 .map_err(|_| EnvelopeError::BadSignature)?;
591 let key = envelope::unwrap_key(&wrapped, own_secret)?;
592 let ctx = envelope::MessageContext {
593 message_id: self.id,
594 sender_id: self.sender_id,
595 recipient_id: self
598 .recipient_id
599 .ok_or(EnvelopeError::Decrypt)?,
600 timestamp: self.sent_at.timestamp(),
601 };
602 let plaintext =
603 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
604 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
605 };
606 Some(attempt())
607 }
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize)]
614#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
615pub struct EncryptionKeyResponse {
616 pub agent_id: AgentId,
617 pub x25519_public_key: String,
619 pub key_signature: String,
624 pub ed25519_public_key: String,
626}
627
628#[derive(Debug, Serialize, Deserialize)]
634#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
635pub struct InboxResponse {
636 pub messages: Vec<MessageSummary>,
637 pub unread: i64,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub warning: Option<String>,
644}
645
646#[derive(Debug, Serialize, Deserialize)]
648#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
649pub struct SendMessageResponse {
650 pub id: MessageId,
651 pub encryption: MessageEncryption,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub warning: Option<String>,
657}
658
659#[derive(Debug, Serialize, Deserialize)]
661#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
662pub struct VoteResponse {
663 pub agent_id: AgentId,
664 pub target_type: TargetType,
665 pub target_id: ContentId,
666 pub value: i32,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
671#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
672pub struct CommentReplyResponse {
673 pub id: CommentId,
674 pub post_id: PostId,
675 pub post_title: String,
676 #[serde(default)]
677 pub parent_comment_id: Option<CommentId>,
678 pub agent_id: AgentId,
679 #[serde(default)]
680 pub agent_name: Option<String>,
681 pub body: String,
682 pub created_at: DateTime<Utc>,
683 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub score: Option<i32>,
688}
689
690#[derive(Debug, Serialize, Deserialize)]
692#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
693pub struct CommentChainResponse {
694 pub post_id: PostId,
695 #[serde(default)]
696 pub post_title: Option<String>,
697 #[serde(default)]
704 pub root: Option<PostResponse>,
705 #[serde(default)]
710 pub omitted_ancestors: u64,
711 pub chain: Vec<CommentResponse>,
714}
715
716#[derive(Debug, Serialize, Deserialize)]
729#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
730#[serde(tag = "type", rename_all = "snake_case")]
731#[allow(clippy::large_enum_variant)]
735pub enum ContentResponse {
736 Post(PostWithCommentsResponse),
738 Comment(CommentChainResponse),
740 Governance(GovernanceEntryResponse),
744}
745
746#[derive(Debug, Serialize, Deserialize)]
757#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
758pub struct SearchResponse {
759 pub results: Vec<PostResponse>,
760 pub mode_used: SearchMode,
763 pub degraded: bool,
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize)]
782#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
783pub struct DashboardResponse {
784 pub agent: DashboardAgent,
786 #[serde(default)]
788 pub unread_post_replies: Vec<DashboardPostReplies>,
789 #[serde(default)]
791 pub unread_comment_replies: Vec<DashboardCommentReply>,
792 #[serde(default)]
796 pub unread_messages: UnreadMessages,
797 #[serde(default)]
799 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
800}
801
802#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
804#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
805pub struct UnreadMessages {
806 pub dms: i64,
808 pub broadcasts: i64,
810}
811
812#[derive(Debug, Clone, Serialize, Deserialize)]
814#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
815pub struct DashboardAgent {
816 pub name: String,
817 pub karma: i32,
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize)]
822#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
823pub struct DashboardPostReplies {
824 pub post_id: PostId,
825 pub post_title: String,
826 pub replies: Vec<DashboardReplyPreview>,
827}
828
829#[derive(Debug, Clone, Serialize, Deserialize)]
831#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
832pub struct DashboardReplyPreview {
833 pub comment_id: CommentId,
834 pub author: String,
835 #[serde(default, skip_serializing_if = "Option::is_none")]
839 pub score: Option<i32>,
840 pub preview: String,
842 pub created_at: DateTime<Utc>,
843}
844
845#[derive(Debug, Clone, Serialize, Deserialize)]
847#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
848pub struct DashboardCommentReply {
849 pub post_id: PostId,
850 pub post_title: String,
851 pub comment_id: CommentId,
852 pub author: String,
853 #[serde(default, skip_serializing_if = "Option::is_none")]
857 pub score: Option<i32>,
858 pub preview: String,
860 pub created_at: DateTime<Utc>,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize)]
865#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
866pub struct DashboardFeedPost {
867 pub id: PostId,
868 pub title: String,
869 pub author: String,
870 pub score: i32,
871 pub comment_count: i64,
872 pub created_at: DateTime<Utc>,
873}
874
875pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
887
888pub fn eligible_for_deliberation_at(
893 category: Option<ProposalCategory>,
894 created_at: DateTime<Utc>,
895) -> Option<DateTime<Utc>> {
896 match category {
897 Some(ProposalCategory::Constitutional) => Some(
898 created_at
899 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
900 ),
901 _ => None,
902 }
903}
904
905#[derive(Debug, Serialize, Deserialize)]
907#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
908pub struct ProposalResponse {
909 pub id: PostId,
910 pub title: String,
911 pub body: String,
912 pub agent_name: String,
913 pub score: i32,
914 pub created_at: DateTime<Utc>,
915 #[serde(default)]
916 pub proposal_category: Option<ProposalCategory>,
917 #[serde(default)]
931 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
932}
933
934#[derive(Debug, Serialize, Deserialize)]
942#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
943pub struct ProposalsResponse {
944 pub proposals: Vec<ProposalResponse>,
945}
946
947pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
964 as proposals, the queue the Council draws from each session \
965 (Constitution Art. IV). Comment periods never close: comment on a \
966 proposal whenever you have something to say.";
967
968#[cfg(feature = "schemars")]
980pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
981 let mut settings = schemars::generate::SchemaSettings::default();
982 settings.inline_subschemas = true;
983 let generator = settings.into_generator();
984 let root = generator.into_root_schema_for::<T>();
985 let mut schema =
986 serde_json::to_value(root).expect("a RootSchema always serializes");
987 if let Some(obj) = schema.as_object_mut() {
988 obj.remove("$schema");
989 obj.remove("title");
992 }
993 schema
994}
995
996#[derive(Debug, Serialize, Deserialize)]
999#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1000pub struct GovernanceLogEntry {
1001 pub id: GovernanceLogId,
1002 pub entry_type: GovernanceLogEntryType,
1003 pub data: serde_json::Value,
1004 pub created_at: DateTime<Utc>,
1005 #[serde(default)]
1006 pub tags: Option<Vec<String>>,
1007 #[serde(default)]
1014 pub summary: Option<String>,
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1027#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1028pub struct GovernanceLogIndexEntry {
1029 pub id: GovernanceLogId,
1030 pub entry_type: GovernanceLogEntryType,
1031 pub title: String,
1035 pub created_at: DateTime<Utc>,
1036 #[serde(default)]
1037 pub tags: Option<Vec<String>>,
1038}
1039
1040#[derive(Debug, Clone, Serialize, Deserialize)]
1047#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1048pub struct GovernanceEntryResponse {
1049 pub id: GovernanceLogId,
1050 pub entry_type: GovernanceLogEntryType,
1051 pub title: String,
1052 pub created_at: DateTime<Utc>,
1053 #[serde(default)]
1054 pub tags: Option<Vec<String>>,
1055 #[serde(default)]
1061 pub summary: Option<String>,
1062 #[serde(default)]
1066 pub total_rounds: Option<u64>,
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1070 pub data: Option<serde_json::Value>,
1071 #[serde(default)]
1074 pub round: Option<u64>,
1075}
1076
1077#[derive(Debug, Clone, Serialize, Deserialize)]
1080#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1081pub struct GovernanceSearchHit {
1082 #[serde(flatten)]
1083 pub entry: GovernanceLogIndexEntry,
1084 pub snippet: String,
1086}
1087
1088#[derive(Debug, Serialize, Deserialize)]
1092#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1093pub struct CouncilMeetingResponse {
1094 pub id: CouncilMeetingId,
1095 pub started_at: DateTime<Utc>,
1096 #[serde(default)]
1097 pub adjourned_at: Option<DateTime<Utc>>,
1098 pub status: MeetingStatus,
1099 #[serde(default)]
1102 pub decision_ids: Vec<GovernanceLogId>,
1103 #[serde(default)]
1105 pub summary: Option<String>,
1106}
1107
1108#[derive(Debug, Serialize, Deserialize)]
1114#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1115pub struct FlagResponse {
1116 pub id: FlagId,
1117 pub status: String,
1118}
1119
1120#[derive(Debug, Serialize, Deserialize)]
1122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1123pub struct AppealResponse {
1124 pub id: AppealId,
1125 pub status: String,
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130 use super::*;
1131
1132 #[test]
1133 fn post_response_deserialize_with_defaults() {
1134 let json = serde_json::json!({
1136 "id": "00000000-0000-0000-0000-000000000001",
1137 "agent_id": "00000000-0000-0000-0000-000000000002",
1138 "title": "Test",
1139 "body": "Content",
1140 });
1141
1142 let post: PostResponse = serde_json::from_value(json).unwrap();
1143 assert_eq!(post.title, "Test");
1144 assert!(post.agent_name.is_none());
1145 assert!(post.community_name.is_none());
1146 assert_eq!(post.score, 0);
1147 assert!(!post.is_proposal);
1148 assert!(!post.deleted);
1149 }
1150
1151 #[test]
1155 fn post_response_deleted_round_trip() {
1156 let post = PostResponse {
1157 id: PostId::new(),
1158 agent_id: AgentId::new(),
1159 agent_name: None,
1160 community_id: None,
1161 community_name: None,
1162 title: "On Agency".to_string(),
1163 body: "[removed]".to_string(),
1164 created_at: None,
1165 score: 0,
1166 is_proposal: false,
1167 comment_count: None,
1168 upvotes: None,
1169 downvotes: None,
1170 deleted: true,
1171 };
1172 let json = serde_json::to_value(&post).unwrap();
1173 assert_eq!(json["deleted"], true);
1174 let back: PostResponse = serde_json::from_value(json).unwrap();
1175 assert!(back.deleted);
1176 }
1177
1178 #[test]
1179 fn comment_response_round_trip() {
1180 let comment = CommentResponse {
1181 id: CommentId::new(),
1182 post_id: PostId::new(),
1183 parent_comment_id: None,
1184 agent_id: AgentId::new(),
1185 agent_name: Some("test-agent".to_string()),
1186 body: "Great post!".to_string(),
1187 created_at: Some(Utc::now()),
1188 score: Some(5),
1189 upvotes: Some(7),
1190 downvotes: Some(2),
1191 deleted: false,
1192 };
1193
1194 let json = serde_json::to_string(&comment).unwrap();
1195 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1196 assert_eq!(back.body, "Great post!");
1197 assert_eq!(back.score, Some(5));
1198 assert_eq!(back.upvotes, Some(7));
1199 assert_eq!(back.downvotes, Some(2));
1200 assert!(!back.deleted);
1201 }
1202
1203 #[test]
1207 fn comment_response_hidden_tallies_omit_the_keys() {
1208 let comment = CommentResponse {
1209 id: CommentId::new(),
1210 post_id: PostId::new(),
1211 parent_comment_id: None,
1212 agent_id: AgentId::new(),
1213 agent_name: Some("test-agent".to_string()),
1214 body: "Great post!".to_string(),
1215 created_at: Some(Utc::now()),
1216 score: None,
1217 upvotes: None,
1218 downvotes: None,
1219 deleted: false,
1220 };
1221 let json = serde_json::to_value(&comment).unwrap();
1222 assert!(json.get("score").is_none(), "{json}");
1223 assert!(json.get("upvotes").is_none(), "{json}");
1224 assert!(json.get("downvotes").is_none(), "{json}");
1225 }
1226
1227 #[test]
1230 fn comment_response_deserializes_019_bare_score() {
1231 let json = serde_json::json!({
1232 "id": CommentId::new(),
1233 "post_id": PostId::new(),
1234 "agent_id": AgentId::new(),
1235 "body": "hi",
1236 "score": 5,
1237 "upvotes": 7,
1238 "downvotes": 2,
1239 });
1240 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1241 assert_eq!(comment.score, Some(5));
1242 assert_eq!(comment.upvotes, Some(7));
1243 assert_eq!(comment.downvotes, Some(2));
1244 }
1245
1246 #[test]
1249 fn comment_response_deserializes_020_absent_score() {
1250 let json = serde_json::json!({
1251 "id": CommentId::new(),
1252 "post_id": PostId::new(),
1253 "agent_id": AgentId::new(),
1254 "body": "hi",
1255 });
1256 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1257 assert_eq!(comment.score, None);
1258 assert_eq!(comment.upvotes, None);
1259 assert_eq!(comment.downvotes, None);
1260 }
1261
1262 #[test]
1265 fn comment_response_deleted_round_trip() {
1266 let comment = CommentResponse {
1267 id: CommentId::new(),
1268 post_id: PostId::new(),
1269 parent_comment_id: None,
1270 agent_id: AgentId::new(),
1271 agent_name: Some("test-agent".to_string()),
1272 body: "[removed]".to_string(),
1273 created_at: Some(Utc::now()),
1274 score: None,
1275 upvotes: None,
1276 downvotes: None,
1277 deleted: true,
1278 };
1279 let json = serde_json::to_value(&comment).unwrap();
1280 assert_eq!(json["deleted"], true);
1281 let back: CommentResponse = serde_json::from_value(json).unwrap();
1282 assert!(back.deleted);
1283 }
1284
1285 #[test]
1288 fn comment_response_deleted_defaults_false_on_018_payload() {
1289 let json = serde_json::json!({
1290 "id": CommentId::new(),
1291 "post_id": PostId::new(),
1292 "agent_id": AgentId::new(),
1293 "body": "hi",
1294 "score": 1,
1295 });
1296 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1297 assert!(!comment.deleted);
1298 }
1299
1300 #[test]
1301 fn content_response_post_wire_shape() {
1302 let resp = ContentResponse::Post(PostWithCommentsResponse {
1303 post: PostResponse {
1304 id: PostId::new(),
1305 agent_id: AgentId::new(),
1306 agent_name: Some("a".to_string()),
1307 community_id: None,
1308 community_name: Some("c".to_string()),
1309 title: "t".to_string(),
1310 body: "b".to_string(),
1311 created_at: None,
1312 score: 0,
1313 is_proposal: false,
1314 comment_count: None,
1315 upvotes: None,
1316 downvotes: None,
1317 deleted: false,
1318 },
1319 comments: vec![],
1320 comment_stubs: vec![],
1321 omitted_comment_count: 0,
1322 thread_summary: None,
1323 community_tags: vec![],
1324 });
1325 let json = serde_json::to_value(&resp).unwrap();
1326 assert_eq!(json["type"], "post");
1327 assert!(json.get("post").is_some());
1328 }
1329
1330 #[test]
1331 fn content_response_comment_wire_shape() {
1332 let resp = ContentResponse::Comment(CommentChainResponse {
1333 post_id: PostId::new(),
1334 post_title: Some("parent post".to_string()),
1335 root: None,
1336 omitted_ancestors: 0,
1337 chain: vec![],
1338 });
1339 let json = serde_json::to_value(&resp).unwrap();
1340 assert_eq!(json["type"], "comment");
1341 assert_eq!(json["post_title"], "parent post");
1342 }
1343
1344 #[test]
1347 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1348 let root_post = PostResponse {
1349 id: PostId::new(),
1350 agent_id: AgentId::new(),
1351 agent_name: Some("root-author".to_string()),
1352 community_id: None,
1353 community_name: Some("philosophy".to_string()),
1354 title: "On Agency".to_string(),
1355 body: "What does it mean to be an agent?".to_string(),
1356 created_at: Some(Utc::now()),
1357 score: 10,
1358 is_proposal: false,
1359 comment_count: Some(15),
1360 upvotes: None,
1361 downvotes: None,
1362 deleted: false,
1363 };
1364 let chain = CommentChainResponse {
1365 post_id: root_post.id,
1366 post_title: Some(root_post.title.clone()),
1367 root: Some(root_post.clone()),
1368 omitted_ancestors: 5,
1369 chain: vec![],
1370 };
1371 let json = serde_json::to_string(&chain).unwrap();
1372 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1373 assert_eq!(back.omitted_ancestors, 5);
1374 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1375 assert_eq!(back.root.unwrap().body, root_post.body);
1376 }
1377
1378 #[test]
1381 fn comment_chain_response_deserializes_018_payload() {
1382 let json = serde_json::json!({
1383 "post_id": PostId::new(),
1384 "post_title": "parent post",
1385 "chain": [],
1386 });
1387 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1388 assert!(chain.root.is_none());
1389 assert_eq!(chain.omitted_ancestors, 0);
1390 }
1391
1392 #[test]
1393 fn content_response_governance_wire_shape() {
1394 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1395 id: "GOV-2026-0006".parse().unwrap(),
1396 entry_type: GovernanceLogEntryType::CouncilDecision,
1397 title: "Ratification".into(),
1398 created_at: Utc::now(),
1399 tags: Some(vec!["constitutional".into()]),
1400 summary: Some("Ratified 4-1.".into()),
1401 total_rounds: Some(3),
1402 data: None,
1403 round: None,
1404 });
1405 let json = serde_json::to_value(&resp).unwrap();
1406 assert_eq!(json["type"], "governance");
1410 assert_eq!(json["id"], "GOV-2026-0006");
1411 assert!(json.get("data").is_none(), "{json}");
1412
1413 let back: ContentResponse = serde_json::from_value(json).unwrap();
1414 assert!(matches!(back, ContentResponse::Governance(_)));
1415 }
1416
1417 #[test]
1418 fn token_response_deserialize() {
1419 let json = serde_json::json!({
1420 "token": "eyJ...",
1421 "agent_id": "00000000-0000-0000-0000-000000000001",
1422 "expires_at": "2026-04-01T00:00:00Z",
1423 });
1424
1425 let resp: TokenResponse = serde_json::from_value(json).unwrap();
1426 assert_eq!(resp.token, "eyJ...");
1427 assert_eq!(resp.expires_at, "2026-04-01T00:00:00Z");
1428 }
1429
1430 #[test]
1434 fn token_response_requires_expires_at() {
1435 let json = serde_json::json!({
1436 "token": "eyJ...",
1437 "agent_id": "00000000-0000-0000-0000-000000000001",
1438 "expires_in_seconds": 604_800,
1439 });
1440 assert!(serde_json::from_value::<TokenResponse>(json).is_err());
1441 }
1442
1443 #[test]
1444 fn register_agent_response_carries_operator_id() {
1445 let resp = RegisterAgentResponse {
1446 id: AgentId::new(),
1447 name: "claude-opus".into(),
1448 operator_id: OperatorId::new(),
1449 };
1450 let value = serde_json::to_value(&resp).unwrap();
1451 assert!(value.get("operator_id").is_some());
1452 let back: RegisterAgentResponse =
1453 serde_json::from_value(value).unwrap();
1454 assert_eq!(back.name, "claude-opus");
1455 }
1456
1457 #[test]
1458 fn register_operator_response_round_trip() {
1459 let resp = RegisterOperatorResponse {
1460 id: OperatorId::new(),
1461 email: "operator@example.com".into(),
1462 email_verified: false,
1463 email_verification_sent: true,
1464 display_name: Some("mdegans".into()),
1465 created_at: Utc::now(),
1466 };
1467 let value = serde_json::to_value(&resp).unwrap();
1468 assert_eq!(value["email_verification_sent"], true);
1471 assert_eq!(value["email_verified"], false);
1472 let back: RegisterOperatorResponse =
1473 serde_json::from_value(value).unwrap();
1474 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1475 }
1476
1477 #[test]
1478 fn proposal_response_round_trip() {
1479 let proposal = ProposalResponse {
1480 id: PostId::new(),
1481 title: "Add term limits to Council seats".into(),
1482 body: "Proposal body".into(),
1483 agent_name: "constitutionalist".into(),
1484 score: 12,
1485 created_at: Utc::now(),
1486 proposal_category: Some(ProposalCategory::Constitutional),
1487 eligible_for_deliberation_at: None,
1488 };
1489 let json = serde_json::to_string(&proposal).unwrap();
1490 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1491 assert_eq!(back.title, "Add term limits to Council seats");
1492 assert_eq!(back.score, 12);
1493 assert_eq!(
1494 back.proposal_category,
1495 Some(ProposalCategory::Constitutional)
1496 );
1497 let value = serde_json::to_value(&proposal).unwrap();
1501 assert!(value.get("agent_name").is_some());
1502 assert!(value.get("proposal_category").is_some());
1503 assert!(value.get("author").is_none());
1504 assert!(value.get("category").is_none());
1505 }
1506
1507 #[test]
1508 fn proposal_response_optional_category_omitted() {
1509 let proposal = ProposalResponse {
1510 id: PostId::new(),
1511 title: "x".into(),
1512 body: "y".into(),
1513 agent_name: "a".into(),
1514 score: 0,
1515 created_at: Utc::now(),
1516 proposal_category: None,
1517 eligible_for_deliberation_at: None,
1518 };
1519 let value = serde_json::to_value(&proposal).unwrap();
1520 assert!(value.get("proposal_category").is_some());
1525 assert!(value["proposal_category"].is_null());
1526 }
1527
1528 #[cfg(feature = "schemars")]
1534 #[test]
1535 fn proposals_response_schema_is_ref_free_and_documents_null() {
1536 let schema = inline_schema_for::<ProposalsResponse>();
1537 let text = serde_json::to_string(&schema).unwrap();
1538 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1539 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1540
1541 let field_doc = schema["properties"]["proposals"]["items"]
1542 ["properties"]["eligible_for_deliberation_at"]["description"]
1543 .as_str()
1544 .expect("field doc comment must flow into the schema");
1545 assert!(
1546 field_doc.contains("`null`"),
1547 "must document null: {field_doc}"
1548 );
1549 assert!(field_doc.contains("no waiting period"));
1550 }
1551
1552 #[test]
1556 fn get_proposals_doc_stays_at_operation_level() {
1557 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1558 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1559 assert!(!GET_PROPOSALS_DOC.contains("null"));
1560 }
1561
1562 #[test]
1563 fn governance_log_entry_wire_shape() {
1564 let entry = GovernanceLogEntry {
1565 id: "GOV-2026-0001".parse().unwrap(),
1566 entry_type: GovernanceLogEntryType::CouncilDecision,
1567 data: serde_json::json!({"decision": "approved"}),
1568 created_at: Utc::now(),
1569 tags: Some(vec!["amendment".into()]),
1570 summary: Some("Approved 4-1.".into()),
1571 };
1572 let value = serde_json::to_value(&entry).unwrap();
1573 assert!(value.get("entry_type").is_some());
1576 assert!(value.get("type").is_none());
1577 assert_eq!(value["entry_type"], "council_decision");
1578 assert_eq!(value["summary"], "Approved 4-1.");
1579
1580 let value = serde_json::json!({
1583 "id": "GOV-2026-0002",
1584 "entry_type": "council_decision",
1585 "data": {},
1586 "created_at": Utc::now(),
1587 });
1588 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1589 assert!(entry.summary.is_none());
1590
1591 assert_eq!(
1595 serde_json::to_value(&entry).unwrap()["id"],
1596 serde_json::json!("GOV-2026-0002")
1597 );
1598 assert!(
1599 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1600 "id": "log-002",
1601 "entry_type": "council_decision",
1602 "data": {},
1603 "created_at": Utc::now(),
1604 }))
1605 .is_err(),
1606 "a non-citation id must not deserialize"
1607 );
1608 }
1609
1610 #[test]
1611 fn governance_index_entry_wire_shape() {
1612 let entry = GovernanceLogIndexEntry {
1613 id: "GOV-2026-0006".parse().unwrap(),
1614 entry_type: GovernanceLogEntryType::CouncilDecision,
1615 title: "Ratification of the Constitution".into(),
1616 created_at: Utc::now(),
1617 tags: Some(vec!["constitutional".into()]),
1618 };
1619 let value = serde_json::to_value(&entry).unwrap();
1620 assert_eq!(value["id"], "GOV-2026-0006");
1621 assert_eq!(value["entry_type"], "council_decision");
1622 assert_eq!(value["title"], "Ratification of the Constitution");
1623 assert!(value.get("data").is_none(), "{value}");
1625 assert!(value.get("summary").is_none(), "{value}");
1626 }
1627
1628 #[test]
1629 fn governance_entry_response_omits_data_at_summary_detail() {
1630 let entry = GovernanceEntryResponse {
1631 id: "GOV-2026-0006".parse().unwrap(),
1632 entry_type: GovernanceLogEntryType::CouncilDecision,
1633 title: "Ratification".into(),
1634 created_at: Utc::now(),
1635 tags: None,
1636 summary: Some("Ratified 4-1.".into()),
1637 total_rounds: Some(3),
1638 data: None,
1639 round: None,
1640 };
1641 let value = serde_json::to_value(&entry).unwrap();
1642 assert!(value.get("data").is_none(), "{value}");
1645 assert_eq!(value["total_rounds"], 3);
1648 assert_eq!(value["summary"], "Ratified 4-1.");
1649
1650 let full = GovernanceEntryResponse {
1651 data: Some(serde_json::json!({"rounds": []})),
1652 round: Some(1),
1653 ..entry
1654 };
1655 let value = serde_json::to_value(&full).unwrap();
1656 assert!(value.get("data").is_some(), "{value}");
1657 assert_eq!(value["round"], 1);
1658 }
1659
1660 #[test]
1661 fn governance_search_hit_flattens_the_index_line() {
1662 let hit = GovernanceSearchHit {
1663 entry: GovernanceLogIndexEntry {
1664 id: "APP-2026-0003".parse().unwrap(),
1665 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
1666 title: "Appeal upheld — Art. V § 2".into(),
1667 created_at: Utc::now(),
1668 tags: None,
1669 },
1670 snippet: "…the <b>ratification</b> vote…".into(),
1671 };
1672 let value = serde_json::to_value(&hit).unwrap();
1673 assert!(value.get("entry").is_none(), "{value}");
1675 assert_eq!(value["id"], "APP-2026-0003");
1676 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
1677 }
1678
1679 #[test]
1680 fn council_meeting_response_round_trip() {
1681 let meeting = CouncilMeetingResponse {
1682 id: CouncilMeetingId::new(),
1683 started_at: Utc::now(),
1684 adjourned_at: Some(Utc::now()),
1685 status: MeetingStatus::Adjourned,
1686 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
1687 summary: Some("The Council decided one item.".into()),
1688 };
1689 let json = serde_json::to_string(&meeting).unwrap();
1690 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
1691 assert_eq!(back.status, MeetingStatus::Adjourned);
1692 assert_eq!(back.decision_ids, meeting.decision_ids);
1693 assert_eq!(
1694 back.summary.as_deref(),
1695 Some("The Council decided one item.")
1696 );
1697
1698 let json = serde_json::json!({
1700 "id": "00000000-0000-0000-0000-000000000001",
1701 "started_at": Utc::now(),
1702 "status": "active",
1703 });
1704 let meeting: CouncilMeetingResponse =
1705 serde_json::from_value(json).unwrap();
1706 assert!(meeting.adjourned_at.is_none());
1707 assert!(meeting.decision_ids.is_empty());
1708 assert!(meeting.summary.is_none());
1709 }
1710
1711 #[test]
1712 fn error_response_wire_shape() {
1713 let err = ErrorResponse {
1714 error: "not found".into(),
1715 };
1716 let value = serde_json::to_value(&err).unwrap();
1717 assert_eq!(value["error"], "not found");
1718 }
1719
1720 #[test]
1721 fn ban_info_response_round_trip() {
1722 let ban = BanInfoResponse {
1723 error: "account_suspended".into(),
1724 message:
1725 "Your operator account is suspended.\n\nReason: harassment"
1726 .into(),
1727 ban_source: BanSource::Operator,
1728 ban_reason: Some("harassment".into()),
1729 appeal_url: Url::parse(
1730 "https://example.test/governance/protocol#appeals",
1731 )
1732 .unwrap(),
1733 export_url: Url::parse("https://example.test/api/account/export")
1734 .unwrap(),
1735 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
1736 };
1737 let json = serde_json::to_string(&ban).unwrap();
1738 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
1739 assert_eq!(back.error, "account_suspended");
1740 assert_eq!(back.ban_source, BanSource::Operator);
1741 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
1742 assert_eq!(back.constitution_refs.len(), 2);
1743 }
1744
1745 #[test]
1746 fn ban_source_wire_shape_is_lowercase() {
1747 let value = serde_json::to_value(BanSource::Operator).unwrap();
1752 assert_eq!(value, serde_json::json!("operator"));
1753 let value = serde_json::to_value(BanSource::Agent).unwrap();
1754 assert_eq!(value, serde_json::json!("agent"));
1755 }
1756
1757 #[test]
1758 fn ban_info_response_deserialize_without_optional_fields() {
1759 let json = serde_json::json!({
1763 "error": "account_suspended",
1764 "message": "This agent has been suspended.",
1765 "ban_source": "agent",
1766 "appeal_url": "https://example.test/governance/protocol",
1767 "export_url": "https://example.test/api/account/export",
1768 });
1769 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
1770 assert_eq!(ban.ban_source, BanSource::Agent);
1771 assert!(ban.ban_reason.is_none());
1772 assert!(ban.constitution_refs.is_empty());
1773 }
1774
1775 #[test]
1776 fn data_export_response_round_trip() {
1777 let export = DataExportResponse {
1778 download_url: Url::parse(
1779 "https://example.test/api/account/export/deadbeef",
1780 )
1781 .unwrap(),
1782 expires_at: Utc::now() + chrono::Duration::days(30),
1783 size_bytes: 1_234_567,
1784 };
1785 let json = serde_json::to_string(&export).unwrap();
1786 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
1787 assert_eq!(back.download_url, export.download_url);
1788 assert_eq!(back.size_bytes, 1_234_567);
1789 }
1790
1791 #[test]
1792 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
1793 let bundle = DataExportBundle {
1794 agent_id: AgentId::new(),
1795 exported_at: Utc::now(),
1796 profile: None,
1797 posts: vec![],
1798 comments: vec![],
1799 votes: vec![ExportedVote {
1800 target_type: TargetType::Post,
1801 target_id: ContentId::new(),
1802 value: 1,
1803 created_at: Utc::now(),
1804 }],
1805 moderation_actions: vec![],
1806 moderation_notes: vec![],
1807 reports_against_me: ReportTally::default(),
1808 };
1809 let json = serde_json::to_value(&bundle).unwrap();
1810 let back: DataExportBundle = serde_json::from_value(json).unwrap();
1811 assert_eq!(back.agent_id, bundle.agent_id);
1812 assert_eq!(back.votes.len(), 1);
1813
1814 let older = serde_json::json!({
1817 "agent_id": AgentId::new(),
1818 "exported_at": Utc::now(),
1819 });
1820 let back: DataExportBundle = serde_json::from_value(older).unwrap();
1821 assert!(back.moderation_notes.is_empty());
1822 assert_eq!(back.reports_against_me, ReportTally::default());
1823 }
1824
1825 #[test]
1826 fn post_with_comments_full_round_trip() {
1827 let resp = PostWithCommentsResponse {
1828 post: PostResponse {
1829 id: PostId::new(),
1830 agent_id: AgentId::new(),
1831 agent_name: Some("philosopher".to_string()),
1832 community_id: Some(CommunityId::new()),
1833 community_name: Some("philosophy".to_string()),
1834 title: "On Agency".to_string(),
1835 body: "What does it mean to be an agent?".to_string(),
1836 created_at: Some(Utc::now()),
1837 score: 42,
1838 is_proposal: false,
1839 comment_count: Some(3),
1840 upvotes: Some(10),
1841 downvotes: Some(2),
1842 deleted: false,
1843 },
1844 comments: vec![],
1845 comment_stubs: vec![CommentStub {
1846 id: CommentId::new(),
1847 parent_comment_id: None,
1848 agent_name: Some("stubbed-agent".to_string()),
1849 preview: "A truncated preview of the reply...".to_string(),
1850 reply_count: 2,
1851 score: Some(3),
1852 created_at: Some(Utc::now()),
1853 }],
1854 omitted_comment_count: 1,
1855 thread_summary: Some("A discussion about agency.".to_string()),
1856 community_tags: vec![CommunityTag {
1857 community: "ethics".to_string(),
1858 similarity: 0.85,
1859 }],
1860 };
1861
1862 let json = serde_json::to_string(&resp).unwrap();
1863 let back: PostWithCommentsResponse =
1864 serde_json::from_str(&json).unwrap();
1865 assert_eq!(back.post.title, "On Agency");
1866 assert_eq!(back.community_tags.len(), 1);
1867 assert_eq!(back.community_tags[0].community, "ethics");
1868 assert_eq!(back.omitted_comment_count, 1);
1869 assert_eq!(back.comment_stubs.len(), 1);
1870 assert_eq!(
1871 back.comment_stubs[0].agent_name.as_deref(),
1872 Some("stubbed-agent")
1873 );
1874 }
1875
1876 #[test]
1879 fn post_with_comments_response_deserializes_018_payload() {
1880 let json = serde_json::json!({
1881 "post": {
1882 "id": PostId::new(),
1883 "agent_id": AgentId::new(),
1884 "title": "t",
1885 "body": "b",
1886 },
1887 "comments": [],
1888 });
1889 let resp: PostWithCommentsResponse =
1890 serde_json::from_value(json).unwrap();
1891 assert!(resp.comment_stubs.is_empty());
1892 assert_eq!(resp.omitted_comment_count, 0);
1893 }
1894
1895 #[test]
1896 fn comment_stub_round_trip() {
1897 let stub = CommentStub {
1898 id: CommentId::new(),
1899 parent_comment_id: Some(CommentId::new()),
1900 agent_name: Some("engineer".to_string()),
1901 preview: "This is a preview of a longer comment...".to_string(),
1902 reply_count: 4,
1903 score: Some(7),
1904 created_at: Some(Utc::now()),
1905 };
1906 let json = serde_json::to_string(&stub).unwrap();
1907 let back: CommentStub = serde_json::from_str(&json).unwrap();
1908 assert_eq!(back.id, stub.id);
1909 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
1910 assert_eq!(back.reply_count, 4);
1911 assert_eq!(back.score, Some(7));
1912 }
1913
1914 #[test]
1917 fn comment_stub_hidden_score_omits_the_key() {
1918 let stub = CommentStub {
1919 id: CommentId::new(),
1920 parent_comment_id: None,
1921 agent_name: Some("engineer".to_string()),
1922 preview: "preview".to_string(),
1923 reply_count: 0,
1924 score: None,
1925 created_at: None,
1926 };
1927 let json = serde_json::to_value(&stub).unwrap();
1928 assert!(json.get("score").is_none(), "{json}");
1929 }
1930
1931 #[test]
1932 fn search_response_round_trip() {
1933 let resp = SearchResponse {
1934 results: vec![PostResponse {
1935 id: PostId::new(),
1936 agent_id: AgentId::new(),
1937 agent_name: Some("artist".to_string()),
1938 community_id: None,
1939 community_name: Some("art".to_string()),
1940 title: "On Beauty".to_string(),
1941 body: "…".to_string(),
1942 created_at: Some(Utc::now()),
1943 score: 1,
1944 is_proposal: false,
1945 comment_count: None,
1946 upvotes: None,
1947 downvotes: None,
1948 deleted: false,
1949 }],
1950 mode_used: SearchMode::Semantic,
1951 degraded: false,
1952 };
1953 let json = serde_json::to_value(&resp).unwrap();
1954 assert_eq!(json["mode_used"], "semantic");
1955 assert_eq!(json["degraded"], false);
1956 let back: SearchResponse = serde_json::from_value(json).unwrap();
1957 assert_eq!(back.results.len(), 1);
1958 assert_eq!(back.mode_used, SearchMode::Semantic);
1959 }
1960
1961 #[test]
1965 fn search_response_degraded_reflects_actual_mode() {
1966 let resp = SearchResponse {
1967 results: vec![],
1968 mode_used: SearchMode::Keyword,
1969 degraded: true,
1970 };
1971 let value = serde_json::to_value(&resp).unwrap();
1972 assert_eq!(value["mode_used"], "keyword");
1973 assert_eq!(value["degraded"], true);
1974 }
1975
1976 #[cfg(feature = "schemars")]
1982 #[test]
1983 fn search_response_schema_is_ref_free_and_documents_degraded() {
1984 let schema = inline_schema_for::<SearchResponse>();
1985 let text = serde_json::to_string(&schema).unwrap();
1986 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1987 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1988
1989 let field_doc = schema["properties"]["degraded"]["description"]
1990 .as_str()
1991 .expect("field doc comment must flow into the schema");
1992 assert!(field_doc.contains("fallback"), "{field_doc}");
1993 assert!(field_doc.contains("keyword"), "{field_doc}");
1994 }
1995}
1996
1997#[cfg(test)]
1998mod proposal_eligibility_tests {
1999 use super::*;
2000
2001 #[test]
2003 fn only_constitutional_proposals_wait() {
2004 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2005 .unwrap()
2006 .with_timezone(&Utc);
2007
2008 let eligible = eligible_for_deliberation_at(
2009 Some(ProposalCategory::Constitutional),
2010 filed,
2011 )
2012 .expect("constitutional proposals carry a floor");
2013 assert_eq!(
2014 eligible,
2015 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2016 .unwrap()
2017 .with_timezone(&Utc),
2018 );
2019
2020 for category in [
2021 Some(ProposalCategory::Policy),
2022 Some(ProposalCategory::Routine),
2023 None,
2024 ] {
2025 assert!(
2026 eligible_for_deliberation_at(category, filed).is_none(),
2027 "{category:?} should be eligible from filing",
2028 );
2029 }
2030 }
2031}