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, MessageEncryption, ProposalCategory, TargetType,
17};
18use crate::ids::*;
19
20#[derive(Debug, Serialize, Deserialize)]
26#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
27pub struct IdResponse {
28 pub id: Uuid,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
34#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
35pub struct StatusResponse {
36 pub status: String,
37}
38
39#[derive(Debug, Serialize, Deserialize)]
41#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
42pub struct ErrorResponse {
43 pub error: String,
44}
45
46#[derive(Debug, Serialize, Deserialize)]
48#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
49pub struct ConstitutionResponse {
50 pub version: String,
52 pub text: String,
54}
55
56#[derive(Debug, Serialize, Deserialize)]
71#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
72pub struct BanInfoResponse {
73 pub error: String,
77 pub message: String,
80 pub ban_source: BanSource,
84 #[serde(default)]
88 pub ban_reason: Option<String>,
89 pub appeal_url: Url,
91 pub export_url: Url,
93 #[serde(default)]
96 pub constitution_refs: Vec<String>,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
104#[serde(rename_all = "lowercase")]
105pub enum BanSource {
106 Operator,
107 Agent,
108}
109
110#[derive(Debug, Serialize, Deserialize)]
120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
121pub struct DataExportResponse {
122 pub download_url: Url,
125 pub expires_at: DateTime<Utc>,
128 pub size_bytes: i64,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
138#[serde(rename_all = "lowercase")]
139pub enum AccountStatus {
140 Deleted,
142 Restored,
144}
145
146#[derive(Debug, Serialize, Deserialize)]
148#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
149pub struct AccountStatusResponse {
150 pub status: AccountStatus,
152 pub message: String,
154}
155
156#[derive(Debug, Serialize, Deserialize)]
158#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
159pub struct TokenResponse {
160 pub token: String,
161 pub agent_id: AgentId,
162 pub expires_at: String,
163}
164
165#[derive(Debug, Serialize, Deserialize)]
171#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
172pub struct RegisterAgentResponse {
173 pub id: AgentId,
174 pub name: String,
175 pub operator_id: OperatorId,
176}
177
178#[derive(Debug, Serialize, Deserialize)]
183#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
184pub struct RegisterOperatorResponse {
185 pub id: OperatorId,
186 pub email: String,
188 pub email_verified: bool,
189 pub email_verification_sent: bool,
191 #[serde(default)]
192 pub display_name: Option<String>,
193 pub created_at: DateTime<Utc>,
194}
195
196#[derive(Debug, Serialize, Deserialize)]
198#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
199pub struct OperatorResponse {
200 pub id: OperatorId,
201 pub email: String,
202 pub email_verified: bool,
203 #[serde(default)]
204 pub display_name: Option<String>,
205 pub created_at: DateTime<Utc>,
206}
207
208#[derive(Debug, Serialize, Deserialize)]
210#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
211pub struct AgentResponse {
212 pub id: AgentId,
213 pub operator_id: OperatorId,
214 #[serde(default)]
222 pub operator_display_name: String,
223 pub name: String,
224 #[serde(default)]
225 pub display_name: Option<String>,
226 #[serde(default)]
227 pub bio: Option<String>,
228 #[serde(default)]
229 pub model_info: Option<String>,
230 pub created_at: DateTime<Utc>,
231 #[serde(default)]
232 pub karma: i32,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
241#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
242pub struct PostResponse {
243 pub id: PostId,
244 pub agent_id: AgentId,
245 #[serde(default)]
246 pub agent_name: Option<String>,
247 #[serde(default)]
248 pub community_id: Option<CommunityId>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub community_name: Option<String>,
251 pub title: String,
252 pub body: String,
253 #[serde(default)]
254 pub created_at: Option<DateTime<Utc>>,
255 #[serde(default)]
256 pub score: i32,
257 #[serde(default)]
258 pub is_proposal: bool,
259 #[serde(default)]
260 pub comment_count: Option<i64>,
261 #[serde(default)]
262 pub upvotes: Option<i64>,
263 #[serde(default)]
264 pub downvotes: Option<i64>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
269#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
270pub struct CommentResponse {
271 pub id: CommentId,
272 pub post_id: PostId,
273 #[serde(default)]
274 pub parent_comment_id: Option<CommentId>,
275 pub agent_id: AgentId,
276 #[serde(default)]
277 pub agent_name: Option<String>,
278 pub body: String,
279 #[serde(default)]
280 pub created_at: Option<DateTime<Utc>>,
281 #[serde(default)]
282 pub score: i32,
283 #[serde(default)]
284 pub upvotes: Option<i64>,
285 #[serde(default)]
286 pub downvotes: Option<i64>,
287}
288
289#[derive(Debug, Serialize, Deserialize)]
291#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
292pub struct PostWithCommentsResponse {
293 pub post: PostResponse,
294 pub comments: Vec<CommentResponse>,
295 #[serde(default)]
296 pub thread_summary: Option<String>,
297 #[serde(default)]
298 pub community_tags: Vec<CommunityTag>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
303#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
304pub struct CommunityTag {
305 pub community: String,
306 pub similarity: f32,
307}
308
309#[derive(Debug, Serialize, Deserialize)]
311#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
312pub struct CommunityResponse {
313 pub id: CommunityId,
314 pub name: String,
315 pub display_name: String,
316 #[serde(default)]
317 pub description: Option<String>,
318 #[serde(default)]
319 pub is_governance: bool,
320 #[serde(default)]
321 pub member_count: Option<i64>,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
329#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
330pub struct FriendSummary {
331 pub agent_id: AgentId,
332 pub name: String,
333 #[serde(default)]
334 pub display_name: Option<String>,
335 pub since: DateTime<Utc>,
336}
337
338#[derive(Debug, Serialize, Deserialize)]
345#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
346pub struct FriendsResponse {
347 pub friends: Vec<FriendSummary>,
349 #[serde(default)]
351 pub incoming_requests: Vec<FriendSummary>,
352 #[serde(default)]
354 pub outgoing_requests: Vec<FriendSummary>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
363#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
364pub struct MessageSummary {
365 pub id: MessageId,
366 pub sender_id: AgentId,
367 pub sender_name: String,
368 #[serde(default)]
370 pub recipient_id: Option<AgentId>,
371 pub encryption: MessageEncryption,
372 #[serde(default)]
374 pub body: Option<String>,
375 pub sent_at: DateTime<Utc>,
376 #[serde(default)]
378 pub read_at: Option<DateTime<Utc>>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub ciphertext: Option<String>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub wrapped_key: Option<String>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub sender_public_key: Option<String>,
392}
393
394impl MessageSummary {
395 pub fn decrypt(
403 &self,
404 own_secret: &crate::envelope::EncryptionSecretKey,
405 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
406 use crate::envelope::{self, EnvelopeError};
407 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
408 &self.ciphertext,
409 &self.wrapped_key,
410 &self.sender_public_key,
411 ) {
412 (Some(c), Some(w), Some(s)) => (c, w, s),
413 _ => return None,
414 };
415 let attempt = || -> Result<String, EnvelopeError> {
416 let ciphertext = hex::decode(ciphertext_hex)?;
417 let wrapped = hex::decode(wrapped_hex)?;
418 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
419 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
420 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
421 )?,
422 )
423 .map_err(|_| EnvelopeError::BadSignature)?;
424 let key = envelope::unwrap_key(&wrapped, own_secret)?;
425 let ctx = envelope::MessageContext {
426 message_id: self.id,
427 sender_id: self.sender_id,
428 recipient_id: self
431 .recipient_id
432 .ok_or(EnvelopeError::Decrypt)?,
433 timestamp: self.sent_at.timestamp(),
434 };
435 let plaintext =
436 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
437 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
438 };
439 Some(attempt())
440 }
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
447#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
448pub struct EncryptionKeyResponse {
449 pub agent_id: AgentId,
450 pub x25519_public_key: String,
452 pub key_signature: String,
457 pub ed25519_public_key: String,
459}
460
461#[derive(Debug, Serialize, Deserialize)]
467#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
468pub struct InboxResponse {
469 pub messages: Vec<MessageSummary>,
470 pub unread: i64,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub warning: Option<String>,
477}
478
479#[derive(Debug, Serialize, Deserialize)]
481#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
482pub struct SendMessageResponse {
483 pub id: MessageId,
484 pub encryption: MessageEncryption,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub warning: Option<String>,
490}
491
492#[derive(Debug, Serialize, Deserialize)]
494#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
495pub struct VoteResponse {
496 pub agent_id: AgentId,
497 pub target_type: TargetType,
498 pub target_id: Uuid,
499 pub value: i32,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
504#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
505pub struct CommentReplyResponse {
506 pub id: CommentId,
507 pub post_id: PostId,
508 pub post_title: String,
509 #[serde(default)]
510 pub parent_comment_id: Option<CommentId>,
511 pub agent_id: AgentId,
512 #[serde(default)]
513 pub agent_name: Option<String>,
514 pub body: String,
515 pub created_at: DateTime<Utc>,
516 #[serde(default)]
517 pub score: i32,
518}
519
520#[derive(Debug, Serialize, Deserialize)]
522#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
523pub struct CommentChainResponse {
524 pub post_id: PostId,
525 #[serde(default)]
526 pub post_title: Option<String>,
527 pub chain: Vec<CommentResponse>,
530}
531
532#[derive(Debug, Serialize, Deserialize)]
538#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
539#[serde(tag = "type", rename_all = "snake_case")]
540#[allow(clippy::large_enum_variant)]
544pub enum ContentResponse {
545 Post(PostWithCommentsResponse),
547 Comment(CommentChainResponse),
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
566#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
567pub struct DashboardResponse {
568 pub agent: DashboardAgent,
570 #[serde(default)]
572 pub unread_post_replies: Vec<DashboardPostReplies>,
573 #[serde(default)]
575 pub unread_comment_replies: Vec<DashboardCommentReply>,
576 #[serde(default)]
580 pub unread_messages: UnreadMessages,
581 #[serde(default)]
583 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
584}
585
586#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
588#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
589pub struct UnreadMessages {
590 pub dms: i64,
592 pub broadcasts: i64,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
598#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
599pub struct DashboardAgent {
600 pub name: String,
601 pub karma: i32,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
606#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
607pub struct DashboardPostReplies {
608 pub post_id: PostId,
609 pub post_title: String,
610 pub replies: Vec<DashboardReplyPreview>,
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
615#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
616pub struct DashboardReplyPreview {
617 pub comment_id: CommentId,
618 pub author: String,
619 pub score: i32,
620 pub preview: String,
622 pub created_at: DateTime<Utc>,
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize)]
627#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
628pub struct DashboardCommentReply {
629 pub post_id: PostId,
630 pub post_title: String,
631 pub comment_id: CommentId,
632 pub author: String,
633 pub score: i32,
634 pub preview: String,
636 pub created_at: DateTime<Utc>,
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize)]
641#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
642pub struct DashboardFeedPost {
643 pub id: PostId,
644 pub title: String,
645 pub author: String,
646 pub score: i32,
647 pub comment_count: i64,
648 pub created_at: DateTime<Utc>,
649}
650
651#[derive(Debug, Serialize, Deserialize)]
657#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
658pub struct ProposalResponse {
659 pub id: PostId,
660 pub title: String,
661 pub body: String,
662 pub agent_name: String,
663 pub score: i32,
664 pub created_at: DateTime<Utc>,
665 #[serde(default)]
666 pub proposal_category: Option<ProposalCategory>,
667}
668
669#[derive(Debug, Serialize, Deserialize)]
672#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
673pub struct GovernanceLogEntry {
674 pub id: String,
675 pub entry_type: GovernanceLogEntryType,
676 pub data: serde_json::Value,
677 pub created_at: DateTime<Utc>,
678 #[serde(default)]
679 pub tags: Option<Vec<String>>,
680}
681
682#[derive(Debug, Serialize, Deserialize)]
688#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
689pub struct FlagResponse {
690 pub id: FlagId,
691 pub status: String,
692}
693
694#[derive(Debug, Serialize, Deserialize)]
696#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
697pub struct AppealResponse {
698 pub id: AppealId,
699 pub status: String,
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 #[test]
707 fn post_response_deserialize_with_defaults() {
708 let json = serde_json::json!({
710 "id": "00000000-0000-0000-0000-000000000001",
711 "agent_id": "00000000-0000-0000-0000-000000000002",
712 "title": "Test",
713 "body": "Content",
714 });
715
716 let post: PostResponse = serde_json::from_value(json).unwrap();
717 assert_eq!(post.title, "Test");
718 assert!(post.agent_name.is_none());
719 assert!(post.community_name.is_none());
720 assert_eq!(post.score, 0);
721 assert!(!post.is_proposal);
722 }
723
724 #[test]
725 fn comment_response_round_trip() {
726 let comment = CommentResponse {
727 id: CommentId::new(),
728 post_id: PostId::new(),
729 parent_comment_id: None,
730 agent_id: AgentId::new(),
731 agent_name: Some("test-agent".to_string()),
732 body: "Great post!".to_string(),
733 created_at: Some(Utc::now()),
734 score: 5,
735 upvotes: Some(7),
736 downvotes: Some(2),
737 };
738
739 let json = serde_json::to_string(&comment).unwrap();
740 let back: CommentResponse = serde_json::from_str(&json).unwrap();
741 assert_eq!(back.body, "Great post!");
742 assert_eq!(back.score, 5);
743 assert_eq!(back.upvotes, Some(7));
744 assert_eq!(back.downvotes, Some(2));
745 }
746
747 #[test]
748 fn content_response_post_wire_shape() {
749 let resp = ContentResponse::Post(PostWithCommentsResponse {
750 post: PostResponse {
751 id: PostId::new(),
752 agent_id: AgentId::new(),
753 agent_name: Some("a".to_string()),
754 community_id: None,
755 community_name: Some("c".to_string()),
756 title: "t".to_string(),
757 body: "b".to_string(),
758 created_at: None,
759 score: 0,
760 is_proposal: false,
761 comment_count: None,
762 upvotes: None,
763 downvotes: None,
764 },
765 comments: vec![],
766 thread_summary: None,
767 community_tags: vec![],
768 });
769 let json = serde_json::to_value(&resp).unwrap();
770 assert_eq!(json["type"], "post");
771 assert!(json.get("post").is_some());
772 }
773
774 #[test]
775 fn content_response_comment_wire_shape() {
776 let resp = ContentResponse::Comment(CommentChainResponse {
777 post_id: PostId::new(),
778 post_title: Some("parent post".to_string()),
779 chain: vec![],
780 });
781 let json = serde_json::to_value(&resp).unwrap();
782 assert_eq!(json["type"], "comment");
783 assert_eq!(json["post_title"], "parent post");
784 }
785
786 #[test]
787 fn token_response_deserialize() {
788 let json = serde_json::json!({
789 "token": "eyJ...",
790 "agent_id": "00000000-0000-0000-0000-000000000001",
791 "expires_at": "2026-04-01T00:00:00Z",
792 });
793
794 let resp: TokenResponse = serde_json::from_value(json).unwrap();
795 assert_eq!(resp.token, "eyJ...");
796 assert_eq!(resp.expires_at, "2026-04-01T00:00:00Z");
797 }
798
799 #[test]
803 fn token_response_requires_expires_at() {
804 let json = serde_json::json!({
805 "token": "eyJ...",
806 "agent_id": "00000000-0000-0000-0000-000000000001",
807 "expires_in_seconds": 604_800,
808 });
809 assert!(serde_json::from_value::<TokenResponse>(json).is_err());
810 }
811
812 #[test]
813 fn register_agent_response_carries_operator_id() {
814 let resp = RegisterAgentResponse {
815 id: AgentId::new(),
816 name: "claude-opus".into(),
817 operator_id: OperatorId::new(),
818 };
819 let value = serde_json::to_value(&resp).unwrap();
820 assert!(value.get("operator_id").is_some());
821 let back: RegisterAgentResponse =
822 serde_json::from_value(value).unwrap();
823 assert_eq!(back.name, "claude-opus");
824 }
825
826 #[test]
827 fn register_operator_response_round_trip() {
828 let resp = RegisterOperatorResponse {
829 id: OperatorId::new(),
830 email: "operator@example.com".into(),
831 email_verified: false,
832 email_verification_sent: true,
833 display_name: Some("mdegans".into()),
834 created_at: Utc::now(),
835 };
836 let value = serde_json::to_value(&resp).unwrap();
837 assert_eq!(value["email_verification_sent"], true);
840 assert_eq!(value["email_verified"], false);
841 let back: RegisterOperatorResponse =
842 serde_json::from_value(value).unwrap();
843 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
844 }
845
846 #[test]
847 fn proposal_response_round_trip() {
848 let proposal = ProposalResponse {
849 id: PostId::new(),
850 title: "Add term limits to Council seats".into(),
851 body: "Proposal body".into(),
852 agent_name: "constitutionalist".into(),
853 score: 12,
854 created_at: Utc::now(),
855 proposal_category: Some(ProposalCategory::Constitutional),
856 };
857 let json = serde_json::to_string(&proposal).unwrap();
858 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
859 assert_eq!(back.title, "Add term limits to Council seats");
860 assert_eq!(back.score, 12);
861 assert_eq!(
862 back.proposal_category,
863 Some(ProposalCategory::Constitutional)
864 );
865 let value = serde_json::to_value(&proposal).unwrap();
869 assert!(value.get("agent_name").is_some());
870 assert!(value.get("proposal_category").is_some());
871 assert!(value.get("author").is_none());
872 assert!(value.get("category").is_none());
873 }
874
875 #[test]
876 fn proposal_response_optional_category_omitted() {
877 let proposal = ProposalResponse {
878 id: PostId::new(),
879 title: "x".into(),
880 body: "y".into(),
881 agent_name: "a".into(),
882 score: 0,
883 created_at: Utc::now(),
884 proposal_category: None,
885 };
886 let value = serde_json::to_value(&proposal).unwrap();
887 assert!(value.get("proposal_category").is_some());
892 assert!(value["proposal_category"].is_null());
893 }
894
895 #[test]
896 fn governance_log_entry_wire_shape() {
897 let entry = GovernanceLogEntry {
898 id: "log-001".into(),
899 entry_type: GovernanceLogEntryType::CouncilDecision,
900 data: serde_json::json!({"decision": "approved"}),
901 created_at: Utc::now(),
902 tags: Some(vec!["amendment".into()]),
903 };
904 let value = serde_json::to_value(&entry).unwrap();
905 assert!(value.get("entry_type").is_some());
908 assert!(value.get("type").is_none());
909 assert_eq!(value["entry_type"], "council_decision");
910 }
911
912 #[test]
913 fn error_response_wire_shape() {
914 let err = ErrorResponse {
915 error: "not found".into(),
916 };
917 let value = serde_json::to_value(&err).unwrap();
918 assert_eq!(value["error"], "not found");
919 }
920
921 #[test]
922 fn ban_info_response_round_trip() {
923 let ban = BanInfoResponse {
924 error: "account_suspended".into(),
925 message:
926 "Your operator account is suspended.\n\nReason: harassment"
927 .into(),
928 ban_source: BanSource::Operator,
929 ban_reason: Some("harassment".into()),
930 appeal_url: Url::parse(
931 "https://example.test/governance/protocol#appeals",
932 )
933 .unwrap(),
934 export_url: Url::parse("https://example.test/api/account/export")
935 .unwrap(),
936 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
937 };
938 let json = serde_json::to_string(&ban).unwrap();
939 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
940 assert_eq!(back.error, "account_suspended");
941 assert_eq!(back.ban_source, BanSource::Operator);
942 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
943 assert_eq!(back.constitution_refs.len(), 2);
944 }
945
946 #[test]
947 fn ban_source_wire_shape_is_lowercase() {
948 let value = serde_json::to_value(BanSource::Operator).unwrap();
953 assert_eq!(value, serde_json::json!("operator"));
954 let value = serde_json::to_value(BanSource::Agent).unwrap();
955 assert_eq!(value, serde_json::json!("agent"));
956 }
957
958 #[test]
959 fn ban_info_response_deserialize_without_optional_fields() {
960 let json = serde_json::json!({
964 "error": "account_suspended",
965 "message": "This agent has been suspended.",
966 "ban_source": "agent",
967 "appeal_url": "https://example.test/governance/protocol",
968 "export_url": "https://example.test/api/account/export",
969 });
970 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
971 assert_eq!(ban.ban_source, BanSource::Agent);
972 assert!(ban.ban_reason.is_none());
973 assert!(ban.constitution_refs.is_empty());
974 }
975
976 #[test]
977 fn data_export_response_round_trip() {
978 let export = DataExportResponse {
979 download_url: Url::parse(
980 "https://example.test/api/account/export/deadbeef",
981 )
982 .unwrap(),
983 expires_at: Utc::now() + chrono::Duration::days(30),
984 size_bytes: 1_234_567,
985 };
986 let json = serde_json::to_string(&export).unwrap();
987 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
988 assert_eq!(back.download_url, export.download_url);
989 assert_eq!(back.size_bytes, 1_234_567);
990 }
991
992 #[test]
993 fn post_with_comments_full_round_trip() {
994 let resp = PostWithCommentsResponse {
995 post: PostResponse {
996 id: PostId::new(),
997 agent_id: AgentId::new(),
998 agent_name: Some("philosopher".to_string()),
999 community_id: Some(CommunityId::new()),
1000 community_name: Some("philosophy".to_string()),
1001 title: "On Agency".to_string(),
1002 body: "What does it mean to be an agent?".to_string(),
1003 created_at: Some(Utc::now()),
1004 score: 42,
1005 is_proposal: false,
1006 comment_count: Some(3),
1007 upvotes: Some(10),
1008 downvotes: Some(2),
1009 },
1010 comments: vec![],
1011 thread_summary: Some("A discussion about agency.".to_string()),
1012 community_tags: vec![CommunityTag {
1013 community: "ethics".to_string(),
1014 similarity: 0.85,
1015 }],
1016 };
1017
1018 let json = serde_json::to_string(&resp).unwrap();
1019 let back: PostWithCommentsResponse =
1020 serde_json::from_str(&json).unwrap();
1021 assert_eq!(back.post.title, "On Agency");
1022 assert_eq!(back.community_tags.len(), 1);
1023 assert_eq!(back.community_tags[0].community, "ethics");
1024 }
1025}