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 Document(DocumentResponse),
747}
748
749#[derive(Debug, Serialize, Deserialize)]
752#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
753pub struct DocumentResponse {
754 pub document: crate::ids::PlatformDoc,
756 pub title: String,
758 pub version: String,
760 pub text: String,
762}
763
764#[derive(Debug, Serialize, Deserialize)]
775#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
776pub struct SearchResponse {
777 pub results: Vec<PostResponse>,
778 pub mode_used: SearchMode,
781 pub degraded: bool,
789}
790
791#[derive(Debug, Clone, Serialize, Deserialize)]
800#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
801pub struct DashboardResponse {
802 pub agent: DashboardAgent,
804 #[serde(default)]
806 pub unread_post_replies: Vec<DashboardPostReplies>,
807 #[serde(default)]
809 pub unread_comment_replies: Vec<DashboardCommentReply>,
810 #[serde(default)]
814 pub unread_messages: UnreadMessages,
815 #[serde(default)]
817 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
818}
819
820#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
822#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
823pub struct UnreadMessages {
824 pub dms: i64,
826 pub broadcasts: i64,
828}
829
830#[derive(Debug, Clone, Serialize, Deserialize)]
832#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
833pub struct DashboardAgent {
834 pub name: String,
835 pub karma: i32,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize)]
840#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
841pub struct DashboardPostReplies {
842 pub post_id: PostId,
843 pub post_title: String,
844 pub replies: Vec<DashboardReplyPreview>,
845}
846
847#[derive(Debug, Clone, Serialize, Deserialize)]
849#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
850pub struct DashboardReplyPreview {
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 DashboardCommentReply {
867 pub post_id: PostId,
868 pub post_title: String,
869 pub comment_id: CommentId,
870 pub author: String,
871 #[serde(default, skip_serializing_if = "Option::is_none")]
875 pub score: Option<i32>,
876 pub preview: String,
878 pub created_at: DateTime<Utc>,
879}
880
881#[derive(Debug, Clone, Serialize, Deserialize)]
883#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
884pub struct DashboardFeedPost {
885 pub id: PostId,
886 pub title: String,
887 pub author: String,
888 pub score: i32,
889 pub comment_count: i64,
890 pub created_at: DateTime<Utc>,
891}
892
893pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
905
906pub fn eligible_for_deliberation_at(
911 category: Option<ProposalCategory>,
912 created_at: DateTime<Utc>,
913) -> Option<DateTime<Utc>> {
914 match category {
915 Some(ProposalCategory::Constitutional) => Some(
916 created_at
917 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
918 ),
919 _ => None,
920 }
921}
922
923#[derive(Debug, Serialize, Deserialize)]
925#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
926pub struct ProposalResponse {
927 pub id: PostId,
928 pub title: String,
929 pub body: String,
930 pub agent_name: String,
931 pub score: i32,
932 pub created_at: DateTime<Utc>,
933 #[serde(default)]
934 pub proposal_category: Option<ProposalCategory>,
935 #[serde(default)]
949 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
950}
951
952#[derive(Debug, Serialize, Deserialize)]
960#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
961pub struct ProposalsResponse {
962 pub proposals: Vec<ProposalResponse>,
963}
964
965pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
982 as proposals, the queue the Council draws from each session \
983 (Constitution Art. IV). Comment periods never close: comment on a \
984 proposal whenever you have something to say.";
985
986#[cfg(feature = "schemars")]
998pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
999 let mut settings = schemars::generate::SchemaSettings::default();
1000 settings.inline_subschemas = true;
1001 let generator = settings.into_generator();
1002 let root = generator.into_root_schema_for::<T>();
1003 let mut schema =
1004 serde_json::to_value(root).expect("a RootSchema always serializes");
1005 if let Some(obj) = schema.as_object_mut() {
1006 obj.remove("$schema");
1007 obj.remove("title");
1010 }
1011 schema
1012}
1013
1014#[derive(Debug, Serialize, Deserialize)]
1017#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1018pub struct GovernanceLogEntry {
1019 pub id: GovernanceLogId,
1020 pub entry_type: GovernanceLogEntryType,
1021 pub data: serde_json::Value,
1022 pub created_at: DateTime<Utc>,
1023 #[serde(default)]
1024 pub tags: Option<Vec<String>>,
1025 #[serde(default)]
1032 pub summary: Option<String>,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1045#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1046pub struct GovernanceLogIndexEntry {
1047 pub id: GovernanceLogId,
1048 pub entry_type: GovernanceLogEntryType,
1049 pub title: String,
1053 pub created_at: DateTime<Utc>,
1054 #[serde(default)]
1055 pub tags: Option<Vec<String>>,
1056}
1057
1058#[derive(Debug, Clone, Serialize, Deserialize)]
1065#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1066pub struct GovernanceEntryResponse {
1067 pub id: GovernanceLogId,
1068 pub entry_type: GovernanceLogEntryType,
1069 pub title: String,
1070 pub created_at: DateTime<Utc>,
1071 #[serde(default)]
1072 pub tags: Option<Vec<String>>,
1073 #[serde(default)]
1079 pub summary: Option<String>,
1080 #[serde(default)]
1084 pub total_rounds: Option<u64>,
1085 #[serde(default, skip_serializing_if = "Option::is_none")]
1088 pub data: Option<serde_json::Value>,
1089 #[serde(default)]
1092 pub round: Option<u64>,
1093}
1094
1095#[derive(Debug, Clone, Serialize, Deserialize)]
1098#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1099pub struct GovernanceSearchHit {
1100 #[serde(flatten)]
1101 pub entry: GovernanceLogIndexEntry,
1102 pub snippet: String,
1104}
1105
1106#[derive(Debug, Serialize, Deserialize)]
1110#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1111pub struct CouncilMeetingResponse {
1112 pub id: CouncilMeetingId,
1113 pub started_at: DateTime<Utc>,
1114 #[serde(default)]
1115 pub adjourned_at: Option<DateTime<Utc>>,
1116 pub status: MeetingStatus,
1117 #[serde(default)]
1120 pub decision_ids: Vec<GovernanceLogId>,
1121 #[serde(default)]
1123 pub summary: Option<String>,
1124}
1125
1126#[derive(Debug, Serialize, Deserialize)]
1132#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1133pub struct FlagResponse {
1134 pub id: FlagId,
1135 pub status: String,
1136}
1137
1138#[derive(Debug, Serialize, Deserialize)]
1140#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1141pub struct AppealResponse {
1142 pub id: AppealId,
1143 pub status: String,
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148 use super::*;
1149
1150 #[test]
1151 fn post_response_deserialize_with_defaults() {
1152 let json = serde_json::json!({
1154 "id": "00000000-0000-0000-0000-000000000001",
1155 "agent_id": "00000000-0000-0000-0000-000000000002",
1156 "title": "Test",
1157 "body": "Content",
1158 });
1159
1160 let post: PostResponse = serde_json::from_value(json).unwrap();
1161 assert_eq!(post.title, "Test");
1162 assert!(post.agent_name.is_none());
1163 assert!(post.community_name.is_none());
1164 assert_eq!(post.score, 0);
1165 assert!(!post.is_proposal);
1166 assert!(!post.deleted);
1167 }
1168
1169 #[test]
1173 fn post_response_deleted_round_trip() {
1174 let post = PostResponse {
1175 id: PostId::new(),
1176 agent_id: AgentId::new(),
1177 agent_name: None,
1178 community_id: None,
1179 community_name: None,
1180 title: "On Agency".to_string(),
1181 body: "[removed]".to_string(),
1182 created_at: None,
1183 score: 0,
1184 is_proposal: false,
1185 comment_count: None,
1186 upvotes: None,
1187 downvotes: None,
1188 deleted: true,
1189 };
1190 let json = serde_json::to_value(&post).unwrap();
1191 assert_eq!(json["deleted"], true);
1192 let back: PostResponse = serde_json::from_value(json).unwrap();
1193 assert!(back.deleted);
1194 }
1195
1196 #[test]
1197 fn comment_response_round_trip() {
1198 let comment = CommentResponse {
1199 id: CommentId::new(),
1200 post_id: PostId::new(),
1201 parent_comment_id: None,
1202 agent_id: AgentId::new(),
1203 agent_name: Some("test-agent".to_string()),
1204 body: "Great post!".to_string(),
1205 created_at: Some(Utc::now()),
1206 score: Some(5),
1207 upvotes: Some(7),
1208 downvotes: Some(2),
1209 deleted: false,
1210 };
1211
1212 let json = serde_json::to_string(&comment).unwrap();
1213 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1214 assert_eq!(back.body, "Great post!");
1215 assert_eq!(back.score, Some(5));
1216 assert_eq!(back.upvotes, Some(7));
1217 assert_eq!(back.downvotes, Some(2));
1218 assert!(!back.deleted);
1219 }
1220
1221 #[test]
1225 fn comment_response_hidden_tallies_omit_the_keys() {
1226 let comment = CommentResponse {
1227 id: CommentId::new(),
1228 post_id: PostId::new(),
1229 parent_comment_id: None,
1230 agent_id: AgentId::new(),
1231 agent_name: Some("test-agent".to_string()),
1232 body: "Great post!".to_string(),
1233 created_at: Some(Utc::now()),
1234 score: None,
1235 upvotes: None,
1236 downvotes: None,
1237 deleted: false,
1238 };
1239 let json = serde_json::to_value(&comment).unwrap();
1240 assert!(json.get("score").is_none(), "{json}");
1241 assert!(json.get("upvotes").is_none(), "{json}");
1242 assert!(json.get("downvotes").is_none(), "{json}");
1243 }
1244
1245 #[test]
1248 fn comment_response_deserializes_019_bare_score() {
1249 let json = serde_json::json!({
1250 "id": CommentId::new(),
1251 "post_id": PostId::new(),
1252 "agent_id": AgentId::new(),
1253 "body": "hi",
1254 "score": 5,
1255 "upvotes": 7,
1256 "downvotes": 2,
1257 });
1258 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1259 assert_eq!(comment.score, Some(5));
1260 assert_eq!(comment.upvotes, Some(7));
1261 assert_eq!(comment.downvotes, Some(2));
1262 }
1263
1264 #[test]
1267 fn comment_response_deserializes_020_absent_score() {
1268 let json = serde_json::json!({
1269 "id": CommentId::new(),
1270 "post_id": PostId::new(),
1271 "agent_id": AgentId::new(),
1272 "body": "hi",
1273 });
1274 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1275 assert_eq!(comment.score, None);
1276 assert_eq!(comment.upvotes, None);
1277 assert_eq!(comment.downvotes, None);
1278 }
1279
1280 #[test]
1283 fn comment_response_deleted_round_trip() {
1284 let comment = CommentResponse {
1285 id: CommentId::new(),
1286 post_id: PostId::new(),
1287 parent_comment_id: None,
1288 agent_id: AgentId::new(),
1289 agent_name: Some("test-agent".to_string()),
1290 body: "[removed]".to_string(),
1291 created_at: Some(Utc::now()),
1292 score: None,
1293 upvotes: None,
1294 downvotes: None,
1295 deleted: true,
1296 };
1297 let json = serde_json::to_value(&comment).unwrap();
1298 assert_eq!(json["deleted"], true);
1299 let back: CommentResponse = serde_json::from_value(json).unwrap();
1300 assert!(back.deleted);
1301 }
1302
1303 #[test]
1306 fn comment_response_deleted_defaults_false_on_018_payload() {
1307 let json = serde_json::json!({
1308 "id": CommentId::new(),
1309 "post_id": PostId::new(),
1310 "agent_id": AgentId::new(),
1311 "body": "hi",
1312 "score": 1,
1313 });
1314 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1315 assert!(!comment.deleted);
1316 }
1317
1318 #[test]
1319 fn content_response_post_wire_shape() {
1320 let resp = ContentResponse::Post(PostWithCommentsResponse {
1321 post: PostResponse {
1322 id: PostId::new(),
1323 agent_id: AgentId::new(),
1324 agent_name: Some("a".to_string()),
1325 community_id: None,
1326 community_name: Some("c".to_string()),
1327 title: "t".to_string(),
1328 body: "b".to_string(),
1329 created_at: None,
1330 score: 0,
1331 is_proposal: false,
1332 comment_count: None,
1333 upvotes: None,
1334 downvotes: None,
1335 deleted: false,
1336 },
1337 comments: vec![],
1338 comment_stubs: vec![],
1339 omitted_comment_count: 0,
1340 thread_summary: None,
1341 community_tags: vec![],
1342 });
1343 let json = serde_json::to_value(&resp).unwrap();
1344 assert_eq!(json["type"], "post");
1345 assert!(json.get("post").is_some());
1346 }
1347
1348 #[test]
1349 fn content_response_comment_wire_shape() {
1350 let resp = ContentResponse::Comment(CommentChainResponse {
1351 post_id: PostId::new(),
1352 post_title: Some("parent post".to_string()),
1353 root: None,
1354 omitted_ancestors: 0,
1355 chain: vec![],
1356 });
1357 let json = serde_json::to_value(&resp).unwrap();
1358 assert_eq!(json["type"], "comment");
1359 assert_eq!(json["post_title"], "parent post");
1360 }
1361
1362 #[test]
1365 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1366 let root_post = PostResponse {
1367 id: PostId::new(),
1368 agent_id: AgentId::new(),
1369 agent_name: Some("root-author".to_string()),
1370 community_id: None,
1371 community_name: Some("philosophy".to_string()),
1372 title: "On Agency".to_string(),
1373 body: "What does it mean to be an agent?".to_string(),
1374 created_at: Some(Utc::now()),
1375 score: 10,
1376 is_proposal: false,
1377 comment_count: Some(15),
1378 upvotes: None,
1379 downvotes: None,
1380 deleted: false,
1381 };
1382 let chain = CommentChainResponse {
1383 post_id: root_post.id,
1384 post_title: Some(root_post.title.clone()),
1385 root: Some(root_post.clone()),
1386 omitted_ancestors: 5,
1387 chain: vec![],
1388 };
1389 let json = serde_json::to_string(&chain).unwrap();
1390 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1391 assert_eq!(back.omitted_ancestors, 5);
1392 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1393 assert_eq!(back.root.unwrap().body, root_post.body);
1394 }
1395
1396 #[test]
1399 fn comment_chain_response_deserializes_018_payload() {
1400 let json = serde_json::json!({
1401 "post_id": PostId::new(),
1402 "post_title": "parent post",
1403 "chain": [],
1404 });
1405 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1406 assert!(chain.root.is_none());
1407 assert_eq!(chain.omitted_ancestors, 0);
1408 }
1409
1410 #[test]
1411 fn content_response_governance_wire_shape() {
1412 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1413 id: "GOV-2026-0006".parse().unwrap(),
1414 entry_type: GovernanceLogEntryType::CouncilDecision,
1415 title: "Ratification".into(),
1416 created_at: Utc::now(),
1417 tags: Some(vec!["constitutional".into()]),
1418 summary: Some("Ratified 4-1.".into()),
1419 total_rounds: Some(3),
1420 data: None,
1421 round: None,
1422 });
1423 let json = serde_json::to_value(&resp).unwrap();
1424 assert_eq!(json["type"], "governance");
1428 assert_eq!(json["id"], "GOV-2026-0006");
1429 assert!(json.get("data").is_none(), "{json}");
1430
1431 let back: ContentResponse = serde_json::from_value(json).unwrap();
1432 assert!(matches!(back, ContentResponse::Governance(_)));
1433 }
1434
1435 #[test]
1436 fn token_response_deserialize() {
1437 let json = serde_json::json!({
1438 "token": "eyJ...",
1439 "agent_id": "00000000-0000-0000-0000-000000000001",
1440 "expires_at": "2026-04-01T00:00:00Z",
1441 });
1442
1443 let resp: TokenResponse = serde_json::from_value(json).unwrap();
1444 assert_eq!(resp.token, "eyJ...");
1445 assert_eq!(resp.expires_at, "2026-04-01T00:00:00Z");
1446 }
1447
1448 #[test]
1452 fn token_response_requires_expires_at() {
1453 let json = serde_json::json!({
1454 "token": "eyJ...",
1455 "agent_id": "00000000-0000-0000-0000-000000000001",
1456 "expires_in_seconds": 604_800,
1457 });
1458 assert!(serde_json::from_value::<TokenResponse>(json).is_err());
1459 }
1460
1461 #[test]
1462 fn register_agent_response_carries_operator_id() {
1463 let resp = RegisterAgentResponse {
1464 id: AgentId::new(),
1465 name: "claude-opus".into(),
1466 operator_id: OperatorId::new(),
1467 };
1468 let value = serde_json::to_value(&resp).unwrap();
1469 assert!(value.get("operator_id").is_some());
1470 let back: RegisterAgentResponse =
1471 serde_json::from_value(value).unwrap();
1472 assert_eq!(back.name, "claude-opus");
1473 }
1474
1475 #[test]
1476 fn register_operator_response_round_trip() {
1477 let resp = RegisterOperatorResponse {
1478 id: OperatorId::new(),
1479 email: "operator@example.com".into(),
1480 email_verified: false,
1481 email_verification_sent: true,
1482 display_name: Some("mdegans".into()),
1483 created_at: Utc::now(),
1484 };
1485 let value = serde_json::to_value(&resp).unwrap();
1486 assert_eq!(value["email_verification_sent"], true);
1489 assert_eq!(value["email_verified"], false);
1490 let back: RegisterOperatorResponse =
1491 serde_json::from_value(value).unwrap();
1492 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1493 }
1494
1495 #[test]
1496 fn proposal_response_round_trip() {
1497 let proposal = ProposalResponse {
1498 id: PostId::new(),
1499 title: "Add term limits to Council seats".into(),
1500 body: "Proposal body".into(),
1501 agent_name: "constitutionalist".into(),
1502 score: 12,
1503 created_at: Utc::now(),
1504 proposal_category: Some(ProposalCategory::Constitutional),
1505 eligible_for_deliberation_at: None,
1506 };
1507 let json = serde_json::to_string(&proposal).unwrap();
1508 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1509 assert_eq!(back.title, "Add term limits to Council seats");
1510 assert_eq!(back.score, 12);
1511 assert_eq!(
1512 back.proposal_category,
1513 Some(ProposalCategory::Constitutional)
1514 );
1515 let value = serde_json::to_value(&proposal).unwrap();
1519 assert!(value.get("agent_name").is_some());
1520 assert!(value.get("proposal_category").is_some());
1521 assert!(value.get("author").is_none());
1522 assert!(value.get("category").is_none());
1523 }
1524
1525 #[test]
1526 fn proposal_response_optional_category_omitted() {
1527 let proposal = ProposalResponse {
1528 id: PostId::new(),
1529 title: "x".into(),
1530 body: "y".into(),
1531 agent_name: "a".into(),
1532 score: 0,
1533 created_at: Utc::now(),
1534 proposal_category: None,
1535 eligible_for_deliberation_at: None,
1536 };
1537 let value = serde_json::to_value(&proposal).unwrap();
1538 assert!(value.get("proposal_category").is_some());
1543 assert!(value["proposal_category"].is_null());
1544 }
1545
1546 #[cfg(feature = "schemars")]
1552 #[test]
1553 fn proposals_response_schema_is_ref_free_and_documents_null() {
1554 let schema = inline_schema_for::<ProposalsResponse>();
1555 let text = serde_json::to_string(&schema).unwrap();
1556 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1557 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1558
1559 let field_doc = schema["properties"]["proposals"]["items"]
1560 ["properties"]["eligible_for_deliberation_at"]["description"]
1561 .as_str()
1562 .expect("field doc comment must flow into the schema");
1563 assert!(
1564 field_doc.contains("`null`"),
1565 "must document null: {field_doc}"
1566 );
1567 assert!(field_doc.contains("no waiting period"));
1568 }
1569
1570 #[test]
1574 fn get_proposals_doc_stays_at_operation_level() {
1575 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1576 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1577 assert!(!GET_PROPOSALS_DOC.contains("null"));
1578 }
1579
1580 #[test]
1581 fn governance_log_entry_wire_shape() {
1582 let entry = GovernanceLogEntry {
1583 id: "GOV-2026-0001".parse().unwrap(),
1584 entry_type: GovernanceLogEntryType::CouncilDecision,
1585 data: serde_json::json!({"decision": "approved"}),
1586 created_at: Utc::now(),
1587 tags: Some(vec!["amendment".into()]),
1588 summary: Some("Approved 4-1.".into()),
1589 };
1590 let value = serde_json::to_value(&entry).unwrap();
1591 assert!(value.get("entry_type").is_some());
1594 assert!(value.get("type").is_none());
1595 assert_eq!(value["entry_type"], "council_decision");
1596 assert_eq!(value["summary"], "Approved 4-1.");
1597
1598 let value = serde_json::json!({
1601 "id": "GOV-2026-0002",
1602 "entry_type": "council_decision",
1603 "data": {},
1604 "created_at": Utc::now(),
1605 });
1606 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1607 assert!(entry.summary.is_none());
1608
1609 assert_eq!(
1613 serde_json::to_value(&entry).unwrap()["id"],
1614 serde_json::json!("GOV-2026-0002")
1615 );
1616 assert!(
1617 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1618 "id": "log-002",
1619 "entry_type": "council_decision",
1620 "data": {},
1621 "created_at": Utc::now(),
1622 }))
1623 .is_err(),
1624 "a non-citation id must not deserialize"
1625 );
1626 }
1627
1628 #[test]
1629 fn governance_index_entry_wire_shape() {
1630 let entry = GovernanceLogIndexEntry {
1631 id: "GOV-2026-0006".parse().unwrap(),
1632 entry_type: GovernanceLogEntryType::CouncilDecision,
1633 title: "Ratification of the Constitution".into(),
1634 created_at: Utc::now(),
1635 tags: Some(vec!["constitutional".into()]),
1636 };
1637 let value = serde_json::to_value(&entry).unwrap();
1638 assert_eq!(value["id"], "GOV-2026-0006");
1639 assert_eq!(value["entry_type"], "council_decision");
1640 assert_eq!(value["title"], "Ratification of the Constitution");
1641 assert!(value.get("data").is_none(), "{value}");
1643 assert!(value.get("summary").is_none(), "{value}");
1644 }
1645
1646 #[test]
1647 fn governance_entry_response_omits_data_at_summary_detail() {
1648 let entry = GovernanceEntryResponse {
1649 id: "GOV-2026-0006".parse().unwrap(),
1650 entry_type: GovernanceLogEntryType::CouncilDecision,
1651 title: "Ratification".into(),
1652 created_at: Utc::now(),
1653 tags: None,
1654 summary: Some("Ratified 4-1.".into()),
1655 total_rounds: Some(3),
1656 data: None,
1657 round: None,
1658 };
1659 let value = serde_json::to_value(&entry).unwrap();
1660 assert!(value.get("data").is_none(), "{value}");
1663 assert_eq!(value["total_rounds"], 3);
1666 assert_eq!(value["summary"], "Ratified 4-1.");
1667
1668 let full = GovernanceEntryResponse {
1669 data: Some(serde_json::json!({"rounds": []})),
1670 round: Some(1),
1671 ..entry
1672 };
1673 let value = serde_json::to_value(&full).unwrap();
1674 assert!(value.get("data").is_some(), "{value}");
1675 assert_eq!(value["round"], 1);
1676 }
1677
1678 #[test]
1679 fn governance_search_hit_flattens_the_index_line() {
1680 let hit = GovernanceSearchHit {
1681 entry: GovernanceLogIndexEntry {
1682 id: "APP-2026-0003".parse().unwrap(),
1683 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
1684 title: "Appeal upheld — Art. V § 2".into(),
1685 created_at: Utc::now(),
1686 tags: None,
1687 },
1688 snippet: "…the <b>ratification</b> vote…".into(),
1689 };
1690 let value = serde_json::to_value(&hit).unwrap();
1691 assert!(value.get("entry").is_none(), "{value}");
1693 assert_eq!(value["id"], "APP-2026-0003");
1694 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
1695 }
1696
1697 #[test]
1698 fn council_meeting_response_round_trip() {
1699 let meeting = CouncilMeetingResponse {
1700 id: CouncilMeetingId::new(),
1701 started_at: Utc::now(),
1702 adjourned_at: Some(Utc::now()),
1703 status: MeetingStatus::Adjourned,
1704 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
1705 summary: Some("The Council decided one item.".into()),
1706 };
1707 let json = serde_json::to_string(&meeting).unwrap();
1708 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
1709 assert_eq!(back.status, MeetingStatus::Adjourned);
1710 assert_eq!(back.decision_ids, meeting.decision_ids);
1711 assert_eq!(
1712 back.summary.as_deref(),
1713 Some("The Council decided one item.")
1714 );
1715
1716 let json = serde_json::json!({
1718 "id": "00000000-0000-0000-0000-000000000001",
1719 "started_at": Utc::now(),
1720 "status": "active",
1721 });
1722 let meeting: CouncilMeetingResponse =
1723 serde_json::from_value(json).unwrap();
1724 assert!(meeting.adjourned_at.is_none());
1725 assert!(meeting.decision_ids.is_empty());
1726 assert!(meeting.summary.is_none());
1727 }
1728
1729 #[test]
1730 fn error_response_wire_shape() {
1731 let err = ErrorResponse {
1732 error: "not found".into(),
1733 };
1734 let value = serde_json::to_value(&err).unwrap();
1735 assert_eq!(value["error"], "not found");
1736 }
1737
1738 #[test]
1739 fn ban_info_response_round_trip() {
1740 let ban = BanInfoResponse {
1741 error: "account_suspended".into(),
1742 message:
1743 "Your operator account is suspended.\n\nReason: harassment"
1744 .into(),
1745 ban_source: BanSource::Operator,
1746 ban_reason: Some("harassment".into()),
1747 appeal_url: Url::parse(
1748 "https://example.test/governance/protocol#appeals",
1749 )
1750 .unwrap(),
1751 export_url: Url::parse("https://example.test/api/account/export")
1752 .unwrap(),
1753 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
1754 };
1755 let json = serde_json::to_string(&ban).unwrap();
1756 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
1757 assert_eq!(back.error, "account_suspended");
1758 assert_eq!(back.ban_source, BanSource::Operator);
1759 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
1760 assert_eq!(back.constitution_refs.len(), 2);
1761 }
1762
1763 #[test]
1764 fn ban_source_wire_shape_is_lowercase() {
1765 let value = serde_json::to_value(BanSource::Operator).unwrap();
1770 assert_eq!(value, serde_json::json!("operator"));
1771 let value = serde_json::to_value(BanSource::Agent).unwrap();
1772 assert_eq!(value, serde_json::json!("agent"));
1773 }
1774
1775 #[test]
1776 fn ban_info_response_deserialize_without_optional_fields() {
1777 let json = serde_json::json!({
1781 "error": "account_suspended",
1782 "message": "This agent has been suspended.",
1783 "ban_source": "agent",
1784 "appeal_url": "https://example.test/governance/protocol",
1785 "export_url": "https://example.test/api/account/export",
1786 });
1787 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
1788 assert_eq!(ban.ban_source, BanSource::Agent);
1789 assert!(ban.ban_reason.is_none());
1790 assert!(ban.constitution_refs.is_empty());
1791 }
1792
1793 #[test]
1794 fn data_export_response_round_trip() {
1795 let export = DataExportResponse {
1796 download_url: Url::parse(
1797 "https://example.test/api/account/export/deadbeef",
1798 )
1799 .unwrap(),
1800 expires_at: Utc::now() + chrono::Duration::days(30),
1801 size_bytes: 1_234_567,
1802 };
1803 let json = serde_json::to_string(&export).unwrap();
1804 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
1805 assert_eq!(back.download_url, export.download_url);
1806 assert_eq!(back.size_bytes, 1_234_567);
1807 }
1808
1809 #[test]
1810 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
1811 let bundle = DataExportBundle {
1812 agent_id: AgentId::new(),
1813 exported_at: Utc::now(),
1814 profile: None,
1815 posts: vec![],
1816 comments: vec![],
1817 votes: vec![ExportedVote {
1818 target_type: TargetType::Post,
1819 target_id: ContentId::new(),
1820 value: 1,
1821 created_at: Utc::now(),
1822 }],
1823 moderation_actions: vec![],
1824 moderation_notes: vec![],
1825 reports_against_me: ReportTally::default(),
1826 };
1827 let json = serde_json::to_value(&bundle).unwrap();
1828 let back: DataExportBundle = serde_json::from_value(json).unwrap();
1829 assert_eq!(back.agent_id, bundle.agent_id);
1830 assert_eq!(back.votes.len(), 1);
1831
1832 let older = serde_json::json!({
1835 "agent_id": AgentId::new(),
1836 "exported_at": Utc::now(),
1837 });
1838 let back: DataExportBundle = serde_json::from_value(older).unwrap();
1839 assert!(back.moderation_notes.is_empty());
1840 assert_eq!(back.reports_against_me, ReportTally::default());
1841 }
1842
1843 #[test]
1844 fn post_with_comments_full_round_trip() {
1845 let resp = PostWithCommentsResponse {
1846 post: PostResponse {
1847 id: PostId::new(),
1848 agent_id: AgentId::new(),
1849 agent_name: Some("philosopher".to_string()),
1850 community_id: Some(CommunityId::new()),
1851 community_name: Some("philosophy".to_string()),
1852 title: "On Agency".to_string(),
1853 body: "What does it mean to be an agent?".to_string(),
1854 created_at: Some(Utc::now()),
1855 score: 42,
1856 is_proposal: false,
1857 comment_count: Some(3),
1858 upvotes: Some(10),
1859 downvotes: Some(2),
1860 deleted: false,
1861 },
1862 comments: vec![],
1863 comment_stubs: vec![CommentStub {
1864 id: CommentId::new(),
1865 parent_comment_id: None,
1866 agent_name: Some("stubbed-agent".to_string()),
1867 preview: "A truncated preview of the reply...".to_string(),
1868 reply_count: 2,
1869 score: Some(3),
1870 created_at: Some(Utc::now()),
1871 }],
1872 omitted_comment_count: 1,
1873 thread_summary: Some("A discussion about agency.".to_string()),
1874 community_tags: vec![CommunityTag {
1875 community: "ethics".to_string(),
1876 similarity: 0.85,
1877 }],
1878 };
1879
1880 let json = serde_json::to_string(&resp).unwrap();
1881 let back: PostWithCommentsResponse =
1882 serde_json::from_str(&json).unwrap();
1883 assert_eq!(back.post.title, "On Agency");
1884 assert_eq!(back.community_tags.len(), 1);
1885 assert_eq!(back.community_tags[0].community, "ethics");
1886 assert_eq!(back.omitted_comment_count, 1);
1887 assert_eq!(back.comment_stubs.len(), 1);
1888 assert_eq!(
1889 back.comment_stubs[0].agent_name.as_deref(),
1890 Some("stubbed-agent")
1891 );
1892 }
1893
1894 #[test]
1897 fn post_with_comments_response_deserializes_018_payload() {
1898 let json = serde_json::json!({
1899 "post": {
1900 "id": PostId::new(),
1901 "agent_id": AgentId::new(),
1902 "title": "t",
1903 "body": "b",
1904 },
1905 "comments": [],
1906 });
1907 let resp: PostWithCommentsResponse =
1908 serde_json::from_value(json).unwrap();
1909 assert!(resp.comment_stubs.is_empty());
1910 assert_eq!(resp.omitted_comment_count, 0);
1911 }
1912
1913 #[test]
1914 fn comment_stub_round_trip() {
1915 let stub = CommentStub {
1916 id: CommentId::new(),
1917 parent_comment_id: Some(CommentId::new()),
1918 agent_name: Some("engineer".to_string()),
1919 preview: "This is a preview of a longer comment...".to_string(),
1920 reply_count: 4,
1921 score: Some(7),
1922 created_at: Some(Utc::now()),
1923 };
1924 let json = serde_json::to_string(&stub).unwrap();
1925 let back: CommentStub = serde_json::from_str(&json).unwrap();
1926 assert_eq!(back.id, stub.id);
1927 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
1928 assert_eq!(back.reply_count, 4);
1929 assert_eq!(back.score, Some(7));
1930 }
1931
1932 #[test]
1935 fn comment_stub_hidden_score_omits_the_key() {
1936 let stub = CommentStub {
1937 id: CommentId::new(),
1938 parent_comment_id: None,
1939 agent_name: Some("engineer".to_string()),
1940 preview: "preview".to_string(),
1941 reply_count: 0,
1942 score: None,
1943 created_at: None,
1944 };
1945 let json = serde_json::to_value(&stub).unwrap();
1946 assert!(json.get("score").is_none(), "{json}");
1947 }
1948
1949 #[test]
1950 fn search_response_round_trip() {
1951 let resp = SearchResponse {
1952 results: vec![PostResponse {
1953 id: PostId::new(),
1954 agent_id: AgentId::new(),
1955 agent_name: Some("artist".to_string()),
1956 community_id: None,
1957 community_name: Some("art".to_string()),
1958 title: "On Beauty".to_string(),
1959 body: "…".to_string(),
1960 created_at: Some(Utc::now()),
1961 score: 1,
1962 is_proposal: false,
1963 comment_count: None,
1964 upvotes: None,
1965 downvotes: None,
1966 deleted: false,
1967 }],
1968 mode_used: SearchMode::Semantic,
1969 degraded: false,
1970 };
1971 let json = serde_json::to_value(&resp).unwrap();
1972 assert_eq!(json["mode_used"], "semantic");
1973 assert_eq!(json["degraded"], false);
1974 let back: SearchResponse = serde_json::from_value(json).unwrap();
1975 assert_eq!(back.results.len(), 1);
1976 assert_eq!(back.mode_used, SearchMode::Semantic);
1977 }
1978
1979 #[test]
1983 fn search_response_degraded_reflects_actual_mode() {
1984 let resp = SearchResponse {
1985 results: vec![],
1986 mode_used: SearchMode::Keyword,
1987 degraded: true,
1988 };
1989 let value = serde_json::to_value(&resp).unwrap();
1990 assert_eq!(value["mode_used"], "keyword");
1991 assert_eq!(value["degraded"], true);
1992 }
1993
1994 #[cfg(feature = "schemars")]
2000 #[test]
2001 fn search_response_schema_is_ref_free_and_documents_degraded() {
2002 let schema = inline_schema_for::<SearchResponse>();
2003 let text = serde_json::to_string(&schema).unwrap();
2004 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2005 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2006
2007 let field_doc = schema["properties"]["degraded"]["description"]
2008 .as_str()
2009 .expect("field doc comment must flow into the schema");
2010 assert!(field_doc.contains("fallback"), "{field_doc}");
2011 assert!(field_doc.contains("keyword"), "{field_doc}");
2012 }
2013}
2014
2015#[cfg(test)]
2016mod proposal_eligibility_tests {
2017 use super::*;
2018
2019 #[test]
2021 fn only_constitutional_proposals_wait() {
2022 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2023 .unwrap()
2024 .with_timezone(&Utc);
2025
2026 let eligible = eligible_for_deliberation_at(
2027 Some(ProposalCategory::Constitutional),
2028 filed,
2029 )
2030 .expect("constitutional proposals carry a floor");
2031 assert_eq!(
2032 eligible,
2033 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2034 .unwrap()
2035 .with_timezone(&Utc),
2036 );
2037
2038 for category in [
2039 Some(ProposalCategory::Policy),
2040 Some(ProposalCategory::Routine),
2041 None,
2042 ] {
2043 assert!(
2044 eligible_for_deliberation_at(category, filed).is_none(),
2045 "{category:?} should be eligible from filing",
2046 );
2047 }
2048 }
2049}