1use serde::{Deserialize, Serialize};
15
16use crate::client::Client;
17use crate::error::{Error, Result};
18use crate::sign::ReleaseCredential;
19use crate::sync::{stamp_schema_version, HasSyncEnvelope, SyncEndpoint, SyncEnvelope, SyncRequest};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum VoiceCallDirection {
27 Inbound,
28 Outbound,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum VoiceCallDisposition {
37 Answered,
38 Missed,
39 Rejected,
40 Cancelled,
41 Failed,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum VoiceCallEndReason {
54 HangupLocal,
55 HangupRemote,
56 RejectedLocal,
57 RejectedRemote,
58 Missed,
59 CancelledLocal,
60 TransferredLocal,
67 ConnectionLost,
73 Failed,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum VoiceCallCodec {
88 Opus,
90 Pcmu,
92 Pcma,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct VoiceCallRecord {
107 pub source_id: String,
110 pub account_id: String,
112 pub direction: VoiceCallDirection,
113 pub party: String,
116 pub ring_at: String,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub answer_at: Option<String>,
122 pub end_at: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub duration_ms: Option<i64>,
129 pub disposition: VoiceCallDisposition,
130 pub end_reason: VoiceCallEndReason,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub error: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub share_visibility: Option<ShareVisibility>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub transfer_target: Option<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub codec: Option<VoiceCallCodec>,
156 #[serde(flatten, default)]
162 pub envelope: SyncEnvelope,
163}
164
165#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct VoiceCallsQuery {
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub before: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub limit: Option<u32>,
176}
177
178pub struct VoiceCalls;
182
183impl SyncEndpoint for VoiceCalls {
184 const RESOURCE: &'static str = "calls";
185 type Record = VoiceCallRecord;
186 type Query = VoiceCallsQuery;
187}
188
189impl HasSyncEnvelope for VoiceCallRecord {
190 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
191 &mut self.envelope
192 }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(rename_all = "camelCase")]
211pub struct VoiceRecordingRecord {
212 pub source_id: String,
215 pub call_source_id: String,
219 pub size_bytes: u64,
223 pub duration_ms: u64,
224 pub sample_rate: u32,
225 pub channels: u16,
226 pub created_at: String,
230 #[serde(flatten, default)]
231 pub envelope: SyncEnvelope,
232}
233
234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase")]
237pub struct VoiceRecordingsQuery {
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub before: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub limit: Option<u32>,
243}
244
245pub struct VoiceRecordings;
253
254impl SyncEndpoint for VoiceRecordings {
255 const RESOURCE: &'static str = "recordings";
256 type Record = VoiceRecordingRecord;
257 type Query = VoiceRecordingsQuery;
258}
259
260impl HasSyncEnvelope for VoiceRecordingRecord {
261 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
262 &mut self.envelope
263 }
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct VoiceRecordingSyncItem {
275 pub source_id: String,
276 pub r2_key: String,
277 pub bytes_uploaded: bool,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct VoiceRecordingsSyncResponse {
285 pub accepted: u32,
286 pub skipped: u32,
287 pub items: Vec<VoiceRecordingSyncItem>,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(rename_all = "snake_case")]
296pub enum VoiceTranscriptChannel {
297 Local,
299 Remote,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
308#[serde(rename_all = "camelCase")]
309pub struct VoiceTranscriptRecord {
310 pub source_id: String,
314 pub call_source_id: String,
316 pub channel: VoiceTranscriptChannel,
317 pub ts_ms: i64,
320 pub end_ms: i64,
322 pub text: String,
324 #[serde(flatten, default)]
325 pub envelope: SyncEnvelope,
326}
327
328#[derive(Debug, Clone, Default, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct VoiceTranscriptsQuery {
333 pub call_source_id: String,
334}
335
336pub struct VoiceTranscripts;
338
339impl SyncEndpoint for VoiceTranscripts {
340 const RESOURCE: &'static str = "transcripts";
341 type Record = VoiceTranscriptRecord;
342 type Query = VoiceTranscriptsQuery;
343}
344
345impl HasSyncEnvelope for VoiceTranscriptRecord {
346 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
347 &mut self.envelope
348 }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(rename_all = "snake_case")]
357pub enum VoiceTransport {
358 Udp,
359 Tcp,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase")]
384pub struct VoiceAccountRecord {
385 pub source_id: String,
390 pub enabled: bool,
393 pub display_name: String,
394 pub username: String,
395 pub domain: String,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub auth_username: Option<String>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub server: Option<String>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub port: Option<u16>,
402 pub transport: VoiceTransport,
403 pub register_expires: u32,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub keepalive_secs: Option<u32>,
406 pub disclosure_enabled: bool,
409 pub updated_at: String,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub deleted_at: Option<String>,
421 #[serde(flatten, default)]
423 pub envelope: SyncEnvelope,
424}
425
426#[derive(Debug, Clone, Default, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct VoiceAccountsQuery {
430 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub include_deleted: Option<bool>,
436}
437
438pub struct VoiceAccounts;
447
448impl SyncEndpoint for VoiceAccounts {
449 const RESOURCE: &'static str = "accounts";
450 type Record = VoiceAccountRecord;
451 type Query = VoiceAccountsQuery;
452}
453
454impl HasSyncEnvelope for VoiceAccountRecord {
455 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
456 &mut self.envelope
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase")]
477pub struct VoiceFlowRecord {
478 pub id: String,
480 pub name: String,
481 pub version: u32,
483 pub yaml: String,
485 pub published_at: String,
487}
488
489#[derive(Debug, Clone, Default, Serialize, Deserialize)]
495#[serde(rename_all = "camelCase")]
496pub struct VoiceFlowsQuery {
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub after: Option<String>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub limit: Option<u32>,
502}
503
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
506#[serde(rename_all = "camelCase")]
507pub struct VoiceFlowsPage {
508 pub items: Vec<VoiceFlowRecord>,
509 #[serde(default)]
511 pub next_after: Option<String>,
512}
513
514impl Client {
515 pub async fn published_flows(&self, query: &VoiceFlowsQuery) -> Result<VoiceFlowsPage> {
519 self.get_json_query::<VoiceFlowsPage, _>("/api/voice/flows/published", query)
520 .await
521 }
522}
523
524#[derive(Debug, Clone, PartialEq, Eq)]
544pub struct SystemInfo {
545 pub os: String,
547 pub os_version: Option<String>,
550 pub arch: String,
552 pub locale: Option<String>,
555}
556
557impl SystemInfo {
558 pub fn detect() -> Self {
561 let os_version = match os_info::get().version() {
562 os_info::Version::Unknown => None,
563 v => Some(v.to_string()),
564 };
565 SystemInfo {
566 os: std::env::consts::OS.to_string(),
567 os_version,
568 arch: std::env::consts::ARCH.to_string(),
569 locale: sys_locale::get_locale(),
570 }
571 }
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(rename_all = "camelCase")]
579pub struct InstallHeartbeatRequest {
580 pub install_id: String,
582 pub app_version: String,
585 pub os: String,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub os_version: Option<String>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub arch: Option<String>,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub locale: Option<String>,
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
596#[serde(rename_all = "camelCase")]
597pub struct InstallHeartbeatResponse {
598 pub id: String,
599 pub install_id: String,
600 pub app_version: String,
601 pub os: String,
602 pub os_version: Option<String>,
603 pub arch: Option<String>,
604 pub locale: Option<String>,
605 pub first_seen_at: String,
606 pub last_seen_at: String,
607}
608
609impl Client {
610 pub async fn install_heartbeat(
627 base_url: &str,
628 install_id: &str,
629 app_version: &str,
630 cred: &ReleaseCredential,
631 ) -> Result<InstallHeartbeatResponse> {
632 let sys = SystemInfo::detect();
633 let body = InstallHeartbeatRequest {
634 install_id: install_id.to_string(),
635 app_version: app_version.to_string(),
636 os: sys.os,
637 os_version: sys.os_version,
638 arch: Some(sys.arch),
639 locale: sys.locale,
640 };
641 Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
642 base_url,
643 "/api/voice/installs/heartbeat",
644 &body,
645 cred,
646 )
647 .await
648 }
649}
650
651impl Client {
666 pub async fn sync_recordings(
674 &self,
675 items: &[VoiceRecordingRecord],
676 ) -> Result<VoiceRecordingsSyncResponse> {
677 let stamped = stamp_schema_version::<VoiceRecordings>(items);
678 let body = SyncRequest { items: stamped };
679 self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
680 .await
681 }
682
683 pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
694 if source_id.is_empty() {
695 return Err(Error::BadRequest("source_id must not be empty".into()));
696 }
697 let path = format!("/api/voice/recordings/{source_id}/bytes");
698 self.put_raw_bytes(&path, "audio/wav", bytes).await
699 }
700}
701
702#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
722#[serde(rename_all = "snake_case")]
723pub enum ShareVisibility {
724 Private,
725 Restricted,
726 Public,
727}
728
729#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
741#[serde(rename_all = "snake_case")]
742pub enum PartyMasking {
743 Full,
744 Partial,
745 None,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize)]
752#[serde(rename_all = "camelCase")]
753pub struct ShareRecordingRequest {
754 pub recording_source_id: String,
757 pub visibility: ShareVisibility,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
761 pub invited_emails: Option<Vec<String>>,
762 #[serde(default, skip_serializing_if = "Option::is_none")]
770 pub party_masking: Option<PartyMasking>,
771 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub show_transcript: Option<bool>,
773 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub show_audio: Option<bool>,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub allow_download: Option<bool>,
782 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub default_mute_local: Option<bool>,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
793 pub default_mute_remote: Option<bool>,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub password: Option<String>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub expires_at: Option<String>,
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize)]
807#[serde(rename_all = "camelCase")]
808pub struct ShareRecordingResponse {
809 pub visibility: ShareVisibility,
810 pub token: String,
811 pub share_url: String,
812 pub shared_at: String,
814 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub party_masking: Option<PartyMasking>,
820 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub show_transcript: Option<bool>,
822 #[serde(default, skip_serializing_if = "Option::is_none")]
823 pub show_audio: Option<bool>,
824 #[serde(default, skip_serializing_if = "Option::is_none")]
828 pub allow_download: Option<bool>,
829 #[serde(default, skip_serializing_if = "Option::is_none")]
833 pub default_mute_local: Option<bool>,
834 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub default_mute_remote: Option<bool>,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize)]
848#[serde(rename_all = "camelCase")]
849pub struct ShareStateResponse {
850 pub visibility: ShareVisibility,
851 #[serde(default, skip_serializing_if = "Option::is_none")]
853 pub token: Option<String>,
854 #[serde(default, skip_serializing_if = "Option::is_none")]
855 pub share_url: Option<String>,
856 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub shared_at: Option<String>,
859 #[serde(default, skip_serializing_if = "Option::is_none")]
862 pub invited_emails: Option<Vec<String>>,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
866 pub party_masking: Option<PartyMasking>,
867 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub show_transcript: Option<bool>,
869 #[serde(default, skip_serializing_if = "Option::is_none")]
870 pub show_audio: Option<bool>,
871 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub allow_download: Option<bool>,
875 #[serde(default, skip_serializing_if = "Option::is_none")]
878 pub default_mute_local: Option<bool>,
879 #[serde(default, skip_serializing_if = "Option::is_none")]
880 pub default_mute_remote: Option<bool>,
881}
882
883impl Client {
884 pub async fn share_recording(
892 &self,
893 req: &ShareRecordingRequest,
894 ) -> Result<ShareRecordingResponse> {
895 if req.recording_source_id.is_empty() {
896 return Err(Error::BadRequest(
897 "recording_source_id must not be empty".into(),
898 ));
899 }
900 let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
901 self.post_json::<ShareRecordingResponse, _>(&path, req)
902 .await
903 }
904
905 pub async fn get_recording_share(
911 &self,
912 recording_source_id: &str,
913 ) -> Result<ShareStateResponse> {
914 if recording_source_id.is_empty() {
915 return Err(Error::BadRequest(
916 "recording_source_id must not be empty".into(),
917 ));
918 }
919 let path = format!("/api/voice/recordings/{recording_source_id}/share");
920 self.get_json::<ShareStateResponse>(&path).await
921 }
922
923 pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
926 if recording_source_id.is_empty() {
927 return Err(Error::BadRequest(
928 "recording_source_id must not be empty".into(),
929 ));
930 }
931 let path = format!("/api/voice/recordings/{recording_source_id}/share");
932 self.delete(&path).await
933 }
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939
940 #[test]
941 fn share_visibility_types_are_reachable_from_the_crate_root() {
942 #[allow(dead_code)]
949 fn _reachable() {
950 let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
951 let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
952 let _: fn(&crate::ShareRecordingRequest) = |_| {};
953 let _: fn(&crate::ShareRecordingResponse) = |_| {};
954 }
955 }
956
957 #[test]
958 fn record_serializes_with_camel_case_keys() {
959 let r = VoiceCallRecord {
960 source_id: "11111111-1111-4111-8111-111111111111".into(),
961 account_id: "22222222-2222-4222-8222-222222222222".into(),
962 direction: VoiceCallDirection::Inbound,
963 party: "+14155550123".into(),
964 ring_at: "2026-05-16T10:00:00Z".into(),
965 answer_at: Some("2026-05-16T10:00:05Z".into()),
966 end_at: "2026-05-16T10:01:00Z".into(),
967 duration_ms: Some(55_000),
968 disposition: VoiceCallDisposition::Answered,
969 end_reason: VoiceCallEndReason::HangupRemote,
970 error: None,
971 share_visibility: None,
972 transfer_target: None,
973 codec: None,
974 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
975 };
976 let s = serde_json::to_string(&r).unwrap();
977 assert!(s.contains("\"sourceId\":"), "{s}");
978 assert!(s.contains("\"accountId\":"), "{s}");
979 assert!(s.contains("\"ringAt\":"), "{s}");
980 assert!(s.contains("\"endAt\":"), "{s}");
981 assert!(s.contains("\"durationMs\":55000"), "{s}");
982 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
984 assert!(
987 !s.contains("\"transferTarget\""),
988 "transferTarget should be omitted: {s}"
989 );
990 assert!(!s.contains("\"codec\""), "codec should be omitted: {s}");
993 assert!(
997 s.contains("\"schemaVersion\":1"),
998 "schemaVersion should flatten: {s}"
999 );
1000 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
1003 }
1004
1005 #[test]
1006 fn record_round_trips_optional_fields() {
1007 let raw = r#"{
1009 "sourceId": "a",
1010 "accountId": "b",
1011 "direction": "inbound",
1012 "party": "anonymous",
1013 "ringAt": "2026-05-16T10:00:00Z",
1014 "endAt": "2026-05-16T10:00:30Z",
1015 "disposition": "missed",
1016 "endReason": "missed"
1017 }"#;
1018 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1019 assert!(parsed.answer_at.is_none());
1020 assert!(parsed.duration_ms.is_none());
1021 assert!(parsed.error.is_none());
1022 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
1023 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
1024 }
1025
1026 #[test]
1027 fn query_omits_unset_fields() {
1028 let q = VoiceCallsQuery::default();
1029 let s = serde_json::to_string(&q).unwrap();
1030 assert_eq!(
1032 s, "{}",
1033 "default query should serialize to empty object: {s}"
1034 );
1035 }
1036
1037 #[test]
1038 fn enum_round_trip_via_json() {
1039 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
1043 let s = serde_json::to_string(&d).unwrap();
1044 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
1045 assert_eq!(d, back);
1046 }
1047 for d in [
1048 VoiceCallDisposition::Answered,
1049 VoiceCallDisposition::Missed,
1050 VoiceCallDisposition::Rejected,
1051 VoiceCallDisposition::Cancelled,
1052 VoiceCallDisposition::Failed,
1053 ] {
1054 let s = serde_json::to_string(&d).unwrap();
1055 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
1056 assert_eq!(d, back);
1057 }
1058 for r in [
1059 VoiceCallEndReason::HangupLocal,
1060 VoiceCallEndReason::HangupRemote,
1061 VoiceCallEndReason::RejectedLocal,
1062 VoiceCallEndReason::RejectedRemote,
1063 VoiceCallEndReason::Missed,
1064 VoiceCallEndReason::CancelledLocal,
1065 VoiceCallEndReason::TransferredLocal,
1066 VoiceCallEndReason::ConnectionLost,
1067 VoiceCallEndReason::Failed,
1068 ] {
1069 let s = serde_json::to_string(&r).unwrap();
1070 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
1071 assert_eq!(r, back);
1072 }
1073 }
1074
1075 #[test]
1076 fn connection_lost_pins_its_wire_string() {
1077 let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
1081 assert_eq!(s, "\"connection_lost\"");
1082 }
1083
1084 #[test]
1085 fn transferred_local_pins_its_wire_string() {
1086 let s = serde_json::to_string(&VoiceCallEndReason::TransferredLocal).unwrap();
1090 assert_eq!(s, "\"transferred_local\"");
1091 }
1092
1093 #[test]
1094 fn record_round_trips_transfer_target() {
1095 let raw = r#"{
1099 "sourceId": "a",
1100 "accountId": "b",
1101 "direction": "inbound",
1102 "party": "Alice <sip:alice@example.com>",
1103 "ringAt": "2026-06-28T10:00:00Z",
1104 "answerAt": "2026-06-28T10:00:05Z",
1105 "endAt": "2026-06-28T10:00:30Z",
1106 "durationMs": 25000,
1107 "disposition": "answered",
1108 "endReason": "transferred_local",
1109 "transferTarget": "1002"
1110 }"#;
1111 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1112 assert_eq!(parsed.end_reason, VoiceCallEndReason::TransferredLocal);
1113 assert_eq!(parsed.transfer_target.as_deref(), Some("1002"));
1114 let s = serde_json::to_string(&parsed).unwrap();
1116 assert!(s.contains("\"transferTarget\":\"1002\""), "{s}");
1117 }
1118
1119 #[test]
1120 fn codec_pins_its_wire_strings() {
1121 for (codec, wire) in [
1126 (VoiceCallCodec::Opus, "\"opus\""),
1127 (VoiceCallCodec::Pcmu, "\"pcmu\""),
1128 (VoiceCallCodec::Pcma, "\"pcma\""),
1129 ] {
1130 assert_eq!(serde_json::to_string(&codec).unwrap(), wire);
1131 let back: VoiceCallCodec = serde_json::from_str(wire).unwrap();
1132 assert_eq!(back, codec);
1133 }
1134 }
1135
1136 #[test]
1137 fn record_round_trips_codec() {
1138 let raw = r#"{
1143 "sourceId": "a",
1144 "accountId": "b",
1145 "direction": "inbound",
1146 "party": "Alice <sip:alice@example.com>",
1147 "ringAt": "2026-07-03T10:00:00Z",
1148 "answerAt": "2026-07-03T10:00:05Z",
1149 "endAt": "2026-07-03T10:00:30Z",
1150 "durationMs": 25000,
1151 "disposition": "answered",
1152 "endReason": "hangup_remote",
1153 "codec": "opus"
1154 }"#;
1155 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1156 assert_eq!(parsed.codec, Some(VoiceCallCodec::Opus));
1157 let s = serde_json::to_string(&parsed).unwrap();
1159 assert!(s.contains("\"codec\":\"opus\""), "{s}");
1160
1161 let legacy = raw.replace(",\n \"codec\": \"opus\"", "");
1163 let parsed: VoiceCallRecord = serde_json::from_str(&legacy).unwrap();
1164 assert_eq!(parsed.codec, None);
1165 }
1166
1167 #[test]
1168 fn voice_calls_marker_resource_is_calls() {
1169 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
1170 }
1171
1172 #[test]
1173 fn record_accepts_unknown_extras_for_forward_compat() {
1174 let raw = r#"{
1180 "sourceId": "a",
1181 "accountId": "b",
1182 "direction": "inbound",
1183 "party": "anon",
1184 "ringAt": "2026-05-16T10:00:00Z",
1185 "endAt": "2026-05-16T10:00:30Z",
1186 "disposition": "answered",
1187 "endReason": "hangup_remote",
1188 "schemaVersion": 2,
1189 "extras": { "notes": "from staging build" }
1190 }"#;
1191 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1192 assert_eq!(parsed.envelope.schema_version, Some(2));
1193 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1194 assert_eq!(extras["notes"], "from staging build");
1195 }
1196
1197 #[test]
1198 fn call_record_parses_share_visibility_from_list_response() {
1199 let raw = r#"{
1202 "sourceId": "a",
1203 "accountId": "b",
1204 "direction": "outbound",
1205 "party": "+14155550123",
1206 "ringAt": "2026-05-16T10:00:00Z",
1207 "endAt": "2026-05-16T10:00:30Z",
1208 "disposition": "answered",
1209 "endReason": "hangup_remote",
1210 "shareVisibility": "public"
1211 }"#;
1212 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1213 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
1214
1215 let restricted = raw.replace("public", "restricted");
1216 let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
1217 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
1218 }
1219
1220 #[test]
1221 fn call_record_unshared_has_no_share_visibility() {
1222 let base = r#"{
1225 "sourceId": "a",
1226 "accountId": "b",
1227 "direction": "inbound",
1228 "party": "anon",
1229 "ringAt": "2026-05-16T10:00:00Z",
1230 "endAt": "2026-05-16T10:00:30Z",
1231 "disposition": "missed",
1232 "endReason": "missed"
1233 }"#;
1234 let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
1235 assert_eq!(parsed.share_visibility, None);
1236
1237 let with_null = base.replace(
1238 r#""endReason": "missed""#,
1239 r#""endReason": "missed", "shareVisibility": null"#,
1240 );
1241 let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1242 assert_eq!(parsed.share_visibility, None);
1243 }
1244
1245 #[test]
1246 fn synced_call_omits_share_visibility() {
1247 let raw = r#"{
1251 "sourceId": "a",
1252 "accountId": "b",
1253 "direction": "inbound",
1254 "party": "anon",
1255 "ringAt": "2026-05-16T10:00:00Z",
1256 "endAt": "2026-05-16T10:00:30Z",
1257 "disposition": "answered",
1258 "endReason": "hangup_remote"
1259 }"#;
1260 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1261 assert_eq!(parsed.share_visibility, None);
1262 let s = serde_json::to_string(&parsed).unwrap();
1263 assert!(
1264 !s.contains("shareVisibility"),
1265 "sync payload leaked share_visibility: {s}"
1266 );
1267 }
1268
1269 #[test]
1270 fn recording_marker_resource_is_recordings() {
1271 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1274 }
1275
1276 #[test]
1277 fn recording_record_serializes_with_camel_case_and_envelope() {
1278 let r = VoiceRecordingRecord {
1279 source_id: "11111111-1111-4111-8111-111111111111".into(),
1280 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1281 size_bytes: 44 + 64_000,
1282 duration_ms: 2_000,
1283 sample_rate: 8_000,
1284 channels: 2,
1285 created_at: "2026-05-16T10:01:05Z".into(),
1286 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1287 };
1288 let s = serde_json::to_string(&r).unwrap();
1289 assert!(s.contains("\"sourceId\":"), "{s}");
1292 assert!(s.contains("\"callSourceId\":"), "{s}");
1293 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1294 assert!(s.contains("\"durationMs\":2000"), "{s}");
1295 assert!(s.contains("\"sampleRate\":8000"), "{s}");
1296 assert!(s.contains("\"channels\":2"), "{s}");
1297 assert!(s.contains("\"createdAt\":"), "{s}");
1298 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1300 }
1301
1302 #[test]
1303 fn recordings_sync_response_round_trips() {
1304 let raw = r#"{
1309 "accepted": 2,
1310 "skipped": 0,
1311 "items": [
1312 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1313 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1314 ]
1315 }"#;
1316 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1317 assert_eq!(parsed.accepted, 2);
1318 assert_eq!(parsed.items.len(), 2);
1319 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1320 assert!(!parsed.items[0].bytes_uploaded);
1321 assert!(parsed.items[1].bytes_uploaded);
1322 }
1323
1324 #[test]
1325 fn install_heartbeat_request_serializes_with_camel_case_keys() {
1326 let req = InstallHeartbeatRequest {
1327 install_id: "11111111-1111-4111-8111-111111111111".into(),
1328 app_version: "0.0.21".into(),
1329 os: "macos".into(),
1330 os_version: Some("15.5.0".into()),
1331 arch: Some("aarch64".into()),
1332 locale: Some("en-NZ".into()),
1333 };
1334 let s = serde_json::to_string(&req).unwrap();
1335 assert!(s.contains("\"installId\":"), "{s}");
1336 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1337 assert!(s.contains("\"os\":\"macos\""), "{s}");
1338 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1339 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1340 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1341 }
1342
1343 #[test]
1344 fn install_heartbeat_request_omits_absent_optional_fields() {
1345 let req = InstallHeartbeatRequest {
1350 install_id: "x".into(),
1351 app_version: "0.0.21".into(),
1352 os: "linux".into(),
1353 os_version: None,
1354 arch: None,
1355 locale: None,
1356 };
1357 let s = serde_json::to_string(&req).unwrap();
1358 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1359 assert!(!s.contains("arch"), "arch should be omitted: {s}");
1360 assert!(!s.contains("locale"), "locale should be omitted: {s}");
1361 }
1362
1363 #[test]
1364 fn install_heartbeat_response_parses_platform_shape() {
1365 let raw = r#"{
1366 "id": "abc-123",
1367 "installId": "11111111-1111-4111-8111-111111111111",
1368 "appVersion": "0.0.21",
1369 "os": "macos",
1370 "osVersion": "15.5.0",
1371 "arch": "aarch64",
1372 "locale": null,
1373 "firstSeenAt": "2026-05-31T10:00:00.000Z",
1374 "lastSeenAt": "2026-05-31T10:00:00.000Z"
1375 }"#;
1376 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1377 assert_eq!(parsed.id, "abc-123");
1378 assert_eq!(parsed.app_version, "0.0.21");
1379 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1380 assert!(parsed.locale.is_none());
1381 }
1382
1383 #[test]
1384 fn system_info_detect_fills_os_and_arch() {
1385 let sys = SystemInfo::detect();
1389 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1390 assert!(
1391 !sys.arch.is_empty(),
1392 "arch should be a non-empty target string"
1393 );
1394 }
1395
1396 #[test]
1397 fn transcripts_marker_resource_is_transcripts() {
1398 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1399 }
1400
1401 #[test]
1402 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1403 let r = VoiceTranscriptRecord {
1404 source_id: "1".into(),
1405 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1406 channel: VoiceTranscriptChannel::Remote,
1407 ts_ms: 100,
1408 end_ms: 1_500,
1409 text: "hello".into(),
1410 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1411 };
1412 let s = serde_json::to_string(&r).unwrap();
1413 assert!(s.contains("\"sourceId\":"), "{s}");
1414 assert!(s.contains("\"callSourceId\":"), "{s}");
1415 assert!(s.contains("\"channel\":\"remote\""), "{s}");
1418 assert!(s.contains("\"tsMs\":100"), "{s}");
1419 assert!(s.contains("\"endMs\":1500"), "{s}");
1420 assert!(s.contains("\"text\":\"hello\""), "{s}");
1421 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1422 }
1423
1424 #[test]
1425 fn share_visibility_pins_its_wire_strings() {
1426 assert_eq!(
1429 serde_json::to_string(&ShareVisibility::Private).unwrap(),
1430 "\"private\""
1431 );
1432 assert_eq!(
1433 serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1434 "\"restricted\""
1435 );
1436 assert_eq!(
1437 serde_json::to_string(&ShareVisibility::Public).unwrap(),
1438 "\"public\""
1439 );
1440 for v in [
1441 ShareVisibility::Private,
1442 ShareVisibility::Restricted,
1443 ShareVisibility::Public,
1444 ] {
1445 let s = serde_json::to_string(&v).unwrap();
1446 let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1447 assert_eq!(v, back);
1448 }
1449 }
1450
1451 #[test]
1452 fn share_request_serializes_with_camel_case_and_omits_unset() {
1453 let req = ShareRecordingRequest {
1454 recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1455 visibility: ShareVisibility::Public,
1456 invited_emails: None,
1457 party_masking: None,
1458 show_transcript: None,
1459 show_audio: None,
1460 allow_download: None,
1461 default_mute_local: None,
1462 default_mute_remote: None,
1463 password: None,
1464 expires_at: None,
1465 };
1466 let s = serde_json::to_string(&req).unwrap();
1467 assert!(s.contains("\"recordingSourceId\":"), "{s}");
1468 assert!(s.contains("\"visibility\":\"public\""), "{s}");
1469 assert!(!s.contains("invitedEmails"), "{s}");
1473 assert!(!s.contains("partyMasking"), "{s}");
1474 assert!(!s.contains("showTranscript"), "{s}");
1475 assert!(!s.contains("showAudio"), "{s}");
1476 assert!(!s.contains("allowDownload"), "{s}");
1477 assert!(!s.contains("defaultMuteLocal"), "{s}");
1478 assert!(!s.contains("defaultMuteRemote"), "{s}");
1479 assert!(!s.contains("password"), "{s}");
1480 assert!(!s.contains("expiresAt"), "{s}");
1481 }
1482
1483 #[test]
1484 fn share_request_serializes_visibility_controls_camel_case() {
1485 let req = ShareRecordingRequest {
1486 recording_source_id: "a".into(),
1487 visibility: ShareVisibility::Public,
1488 invited_emails: None,
1489 party_masking: Some(PartyMasking::Partial),
1490 show_transcript: Some(false),
1491 show_audio: Some(true),
1492 allow_download: Some(true),
1493 default_mute_local: Some(false),
1494 default_mute_remote: Some(true),
1495 password: None,
1496 expires_at: None,
1497 };
1498 let s = serde_json::to_string(&req).unwrap();
1499 assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1500 assert!(s.contains("\"showTranscript\":false"), "{s}");
1501 assert!(s.contains("\"showAudio\":true"), "{s}");
1502 assert!(s.contains("\"allowDownload\":true"), "{s}");
1503 assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
1506 assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
1507 }
1508
1509 #[test]
1510 fn share_request_carries_invited_emails_for_restricted() {
1511 let req = ShareRecordingRequest {
1512 recording_source_id: "a".into(),
1513 visibility: ShareVisibility::Restricted,
1514 invited_emails: Some(vec!["alex@example.com".into()]),
1515 party_masking: None,
1516 show_transcript: None,
1517 show_audio: None,
1518 allow_download: None,
1519 default_mute_local: None,
1520 default_mute_remote: None,
1521 password: None,
1522 expires_at: None,
1523 };
1524 let s = serde_json::to_string(&req).unwrap();
1525 assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1526 assert!(
1527 s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1528 "{s}"
1529 );
1530 }
1531
1532 #[test]
1533 fn share_response_parses_platform_shape() {
1534 let raw = r#"{
1535 "visibility": "public",
1536 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1537 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1538 "sharedAt": "2026-06-19T10:00:00.000Z"
1539 }"#;
1540 let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1541 assert_eq!(parsed.visibility, ShareVisibility::Public);
1542 assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1543 assert!(parsed.share_url.ends_with(&parsed.token));
1544 }
1545
1546 #[test]
1547 fn share_state_parses_restricted_with_invited_emails() {
1548 let raw = r#"{
1551 "visibility": "restricted",
1552 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1553 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1554 "sharedAt": "2026-06-19T10:00:00.000Z",
1555 "invitedEmails": ["bob@example.com", "carol@example.com"],
1556 "partyMasking": "full",
1557 "showTranscript": true,
1558 "showAudio": false,
1559 "allowDownload": false,
1560 "defaultMuteLocal": false,
1561 "defaultMuteRemote": true
1562 }"#;
1563 let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1564 assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1565 assert_eq!(
1566 parsed.invited_emails.as_deref(),
1567 Some(
1568 [
1569 "bob@example.com".to_string(),
1570 "carol@example.com".to_string()
1571 ]
1572 .as_slice()
1573 )
1574 );
1575 assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1577 assert_eq!(parsed.show_transcript, Some(true));
1578 assert_eq!(parsed.show_audio, Some(false));
1579 assert_eq!(parsed.allow_download, Some(false));
1581 assert_eq!(parsed.default_mute_local, Some(false));
1583 assert_eq!(parsed.default_mute_remote, Some(true));
1584 }
1585
1586 #[test]
1587 fn share_state_parses_private_with_fields_absent() {
1588 let parsed: ShareStateResponse =
1591 serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1592 assert_eq!(parsed.visibility, ShareVisibility::Private);
1593 assert!(parsed.token.is_none());
1594 assert!(parsed.share_url.is_none());
1595 assert!(parsed.shared_at.is_none());
1596 assert!(parsed.invited_emails.is_none());
1597 }
1598
1599 #[test]
1600 fn share_request_rejects_empty_source_id_before_hitting_network() {
1601 let req = ShareRecordingRequest {
1604 recording_source_id: String::new(),
1605 visibility: ShareVisibility::Private,
1606 invited_emails: None,
1607 party_masking: None,
1608 show_transcript: None,
1609 show_audio: None,
1610 allow_download: None,
1611 default_mute_local: None,
1612 default_mute_remote: None,
1613 password: None,
1614 expires_at: None,
1615 };
1616 assert!(req.recording_source_id.is_empty());
1620 }
1621
1622 fn sample_account() -> VoiceAccountRecord {
1625 VoiceAccountRecord {
1626 source_id: "11111111-1111-4111-8111-111111111111".into(),
1627 enabled: true,
1628 display_name: "Work line".into(),
1629 username: "alice".into(),
1630 domain: "sip.example.com".into(),
1631 auth_username: Some("alice-auth".into()),
1632 server: Some("sip.example.com".into()),
1633 port: Some(5060),
1634 transport: VoiceTransport::Udp,
1635 register_expires: 60,
1636 keepalive_secs: Some(50),
1637 disclosure_enabled: true,
1638 updated_at: "2026-06-20T10:00:00Z".into(),
1639 deleted_at: None,
1640 envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1641 }
1642 }
1643
1644 #[test]
1645 fn accounts_marker_resource_is_accounts() {
1646 assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1649 }
1650
1651 #[test]
1652 fn account_record_serializes_with_camel_case_and_envelope() {
1653 let s = serde_json::to_string(&sample_account()).unwrap();
1654 assert!(s.contains("\"sourceId\":"), "{s}");
1657 assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1658 assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1659 assert!(s.contains("\"registerExpires\":60"), "{s}");
1660 assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1661 assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1662 assert!(s.contains("\"transport\":\"udp\""), "{s}");
1663 assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1664 assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1666 assert!(!s.contains("password"), "no password field: {s}");
1668 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1670 }
1671
1672 #[test]
1673 fn account_tombstone_serializes_deleted_at() {
1674 let mut r = sample_account();
1677 r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1678 let s = serde_json::to_string(&r).unwrap();
1679 assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1680 }
1681
1682 #[test]
1683 fn account_record_round_trips_optional_fields() {
1684 let raw = r#"{
1687 "sourceId": "a",
1688 "enabled": false,
1689 "displayName": "Cheap trunk",
1690 "username": "u",
1691 "domain": "d",
1692 "transport": "tcp",
1693 "registerExpires": 120,
1694 "disclosureEnabled": false,
1695 "updatedAt": "2026-06-20T10:00:00Z"
1696 }"#;
1697 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1698 assert!(!parsed.enabled);
1699 assert!(parsed.auth_username.is_none());
1700 assert!(parsed.server.is_none());
1701 assert!(parsed.port.is_none());
1702 assert!(parsed.keepalive_secs.is_none());
1703 assert!(parsed.deleted_at.is_none());
1704 assert_eq!(parsed.transport, VoiceTransport::Tcp);
1705 assert_eq!(parsed.register_expires, 120);
1706 }
1707
1708 #[test]
1709 fn voice_transport_round_trips_via_json() {
1710 for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1711 let s = serde_json::to_string(&t).unwrap();
1712 let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1713 assert_eq!(t, back);
1714 }
1715 assert_eq!(
1718 serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1719 "\"udp\""
1720 );
1721 assert_eq!(
1722 serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1723 "\"tcp\""
1724 );
1725 }
1726
1727 #[test]
1728 fn accounts_query_omits_unset_and_serializes_include_deleted() {
1729 let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1730 assert_eq!(empty, "{}", "default query should be empty: {empty}");
1731 let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1732 include_deleted: Some(true),
1733 })
1734 .unwrap();
1735 assert!(
1736 with_deleted.contains("\"includeDeleted\":true"),
1737 "{with_deleted}"
1738 );
1739 }
1740
1741 #[test]
1744 fn flows_query_serializes_cursor_and_omits_absent_fields() {
1745 let empty = serde_json::to_string(&VoiceFlowsQuery::default()).unwrap();
1746 assert_eq!(empty, "{}");
1747 let cursored = serde_json::to_string(&VoiceFlowsQuery {
1748 after: Some("flow_abc".into()),
1749 limit: Some(100),
1750 })
1751 .unwrap();
1752 assert!(cursored.contains("\"after\":\"flow_abc\""), "{cursored}");
1753 assert!(cursored.contains("\"limit\":100"), "{cursored}");
1754 }
1755
1756 #[test]
1757 fn flows_page_parses_platform_shape() {
1758 let raw = r#"{
1759 "items": [{
1760 "id": "flow_1",
1761 "name": "Luigi's — after hours",
1762 "version": 3,
1763 "yaml": "schema_version: 1\n",
1764 "publishedAt": "2026-07-13T10:00:00Z"
1765 }],
1766 "nextAfter": null
1767 }"#;
1768 let page: VoiceFlowsPage = serde_json::from_str(raw).unwrap();
1769 assert_eq!(page.items.len(), 1);
1770 let rec = &page.items[0];
1771 assert_eq!(rec.id, "flow_1");
1772 assert_eq!(rec.version, 3);
1773 assert_eq!(rec.published_at, "2026-07-13T10:00:00Z");
1774 assert!(page.next_after.is_none());
1775
1776 let more: VoiceFlowsPage =
1778 serde_json::from_str(r#"{ "items": [], "nextAfter": "flow_1" }"#).unwrap();
1779 assert_eq!(more.next_after.as_deref(), Some("flow_1"));
1780 }
1781
1782 #[test]
1783 fn account_record_accepts_unknown_extras_for_forward_compat() {
1784 let raw = r#"{
1787 "sourceId": "a",
1788 "enabled": true,
1789 "displayName": "x",
1790 "username": "u",
1791 "domain": "d",
1792 "transport": "udp",
1793 "registerExpires": 60,
1794 "disclosureEnabled": true,
1795 "updatedAt": "2026-06-20T10:00:00Z",
1796 "schemaVersion": 2,
1797 "extras": { "ringtone": "classic" }
1798 }"#;
1799 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1800 assert_eq!(parsed.envelope.schema_version, Some(2));
1801 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1802 assert_eq!(extras["ringtone"], "classic");
1803 }
1804}