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 ConnectionLost,
66 Failed,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct VoiceCallRecord {
80 pub source_id: String,
83 pub account_id: String,
85 pub direction: VoiceCallDirection,
86 pub party: String,
89 pub ring_at: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub answer_at: Option<String>,
95 pub end_at: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub duration_ms: Option<i64>,
102 pub disposition: VoiceCallDisposition,
103 pub end_reason: VoiceCallEndReason,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub error: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub share_visibility: Option<ShareVisibility>,
115 #[serde(flatten, default)]
121 pub envelope: SyncEnvelope,
122}
123
124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct VoiceCallsQuery {
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub before: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub limit: Option<u32>,
135}
136
137pub struct VoiceCalls;
141
142impl SyncEndpoint for VoiceCalls {
143 const RESOURCE: &'static str = "calls";
144 type Record = VoiceCallRecord;
145 type Query = VoiceCallsQuery;
146}
147
148impl HasSyncEnvelope for VoiceCallRecord {
149 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
150 &mut self.envelope
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct VoiceRecordingRecord {
171 pub source_id: String,
174 pub call_source_id: String,
178 pub size_bytes: u64,
182 pub duration_ms: u64,
183 pub sample_rate: u32,
184 pub channels: u16,
185 pub created_at: String,
189 #[serde(flatten, default)]
190 pub envelope: SyncEnvelope,
191}
192
193#[derive(Debug, Clone, Default, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct VoiceRecordingsQuery {
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub before: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub limit: Option<u32>,
202}
203
204pub struct VoiceRecordings;
212
213impl SyncEndpoint for VoiceRecordings {
214 const RESOURCE: &'static str = "recordings";
215 type Record = VoiceRecordingRecord;
216 type Query = VoiceRecordingsQuery;
217}
218
219impl HasSyncEnvelope for VoiceRecordingRecord {
220 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
221 &mut self.envelope
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct VoiceRecordingSyncItem {
234 pub source_id: String,
235 pub r2_key: String,
236 pub bytes_uploaded: bool,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct VoiceRecordingsSyncResponse {
244 pub accepted: u32,
245 pub skipped: u32,
246 pub items: Vec<VoiceRecordingSyncItem>,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum VoiceTranscriptChannel {
256 Local,
258 Remote,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct VoiceTranscriptRecord {
269 pub source_id: String,
273 pub call_source_id: String,
275 pub channel: VoiceTranscriptChannel,
276 pub ts_ms: i64,
279 pub end_ms: i64,
281 pub text: String,
283 #[serde(flatten, default)]
284 pub envelope: SyncEnvelope,
285}
286
287#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct VoiceTranscriptsQuery {
292 pub call_source_id: String,
293}
294
295pub struct VoiceTranscripts;
297
298impl SyncEndpoint for VoiceTranscripts {
299 const RESOURCE: &'static str = "transcripts";
300 type Record = VoiceTranscriptRecord;
301 type Query = VoiceTranscriptsQuery;
302}
303
304impl HasSyncEnvelope for VoiceTranscriptRecord {
305 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
306 &mut self.envelope
307 }
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316pub enum VoiceTransport {
317 Udp,
318 Tcp,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase")]
343pub struct VoiceAccountRecord {
344 pub source_id: String,
349 pub enabled: bool,
352 pub display_name: String,
353 pub username: String,
354 pub domain: String,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub auth_username: Option<String>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub server: Option<String>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub port: Option<u16>,
361 pub transport: VoiceTransport,
362 pub register_expires: u32,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub keepalive_secs: Option<u32>,
365 pub disclosure_enabled: bool,
368 pub updated_at: String,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub deleted_at: Option<String>,
380 #[serde(flatten, default)]
382 pub envelope: SyncEnvelope,
383}
384
385#[derive(Debug, Clone, Default, Serialize, Deserialize)]
387#[serde(rename_all = "camelCase")]
388pub struct VoiceAccountsQuery {
389 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub include_deleted: Option<bool>,
395}
396
397pub struct VoiceAccounts;
406
407impl SyncEndpoint for VoiceAccounts {
408 const RESOURCE: &'static str = "accounts";
409 type Record = VoiceAccountRecord;
410 type Query = VoiceAccountsQuery;
411}
412
413impl HasSyncEnvelope for VoiceAccountRecord {
414 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
415 &mut self.envelope
416 }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
439pub struct SystemInfo {
440 pub os: String,
442 pub os_version: Option<String>,
445 pub arch: String,
447 pub locale: Option<String>,
450}
451
452impl SystemInfo {
453 pub fn detect() -> Self {
456 let os_version = match os_info::get().version() {
457 os_info::Version::Unknown => None,
458 v => Some(v.to_string()),
459 };
460 SystemInfo {
461 os: std::env::consts::OS.to_string(),
462 os_version,
463 arch: std::env::consts::ARCH.to_string(),
464 locale: sys_locale::get_locale(),
465 }
466 }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub struct InstallHeartbeatRequest {
475 pub install_id: String,
477 pub app_version: String,
480 pub os: String,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub os_version: Option<String>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub arch: Option<String>,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub locale: Option<String>,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
491#[serde(rename_all = "camelCase")]
492pub struct InstallHeartbeatResponse {
493 pub id: String,
494 pub install_id: String,
495 pub app_version: String,
496 pub os: String,
497 pub os_version: Option<String>,
498 pub arch: Option<String>,
499 pub locale: Option<String>,
500 pub first_seen_at: String,
501 pub last_seen_at: String,
502}
503
504impl Client {
505 pub async fn install_heartbeat(
522 base_url: &str,
523 install_id: &str,
524 app_version: &str,
525 cred: &ReleaseCredential,
526 ) -> Result<InstallHeartbeatResponse> {
527 let sys = SystemInfo::detect();
528 let body = InstallHeartbeatRequest {
529 install_id: install_id.to_string(),
530 app_version: app_version.to_string(),
531 os: sys.os,
532 os_version: sys.os_version,
533 arch: Some(sys.arch),
534 locale: sys.locale,
535 };
536 Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
537 base_url,
538 "/api/voice/installs/heartbeat",
539 &body,
540 cred,
541 )
542 .await
543 }
544}
545
546impl Client {
561 pub async fn sync_recordings(
569 &self,
570 items: &[VoiceRecordingRecord],
571 ) -> Result<VoiceRecordingsSyncResponse> {
572 let stamped = stamp_schema_version::<VoiceRecordings>(items);
573 let body = SyncRequest { items: stamped };
574 self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
575 .await
576 }
577
578 pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
589 if source_id.is_empty() {
590 return Err(Error::BadRequest("source_id must not be empty".into()));
591 }
592 let path = format!("/api/voice/recordings/{source_id}/bytes");
593 self.put_raw_bytes(&path, "audio/wav", bytes).await
594 }
595}
596
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
617#[serde(rename_all = "snake_case")]
618pub enum ShareVisibility {
619 Private,
620 Restricted,
621 Public,
622}
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
636#[serde(rename_all = "snake_case")]
637pub enum PartyMasking {
638 Full,
639 Partial,
640 None,
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize)]
647#[serde(rename_all = "camelCase")]
648pub struct ShareRecordingRequest {
649 pub recording_source_id: String,
652 pub visibility: ShareVisibility,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub invited_emails: Option<Vec<String>>,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub party_masking: Option<PartyMasking>,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub show_transcript: Option<bool>,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub show_audio: Option<bool>,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub allow_download: Option<bool>,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub password: Option<String>,
680 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub expires_at: Option<String>,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize)]
690#[serde(rename_all = "camelCase")]
691pub struct ShareRecordingResponse {
692 pub visibility: ShareVisibility,
693 pub token: String,
694 pub share_url: String,
695 pub shared_at: String,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
702 pub party_masking: Option<PartyMasking>,
703 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub show_transcript: Option<bool>,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub show_audio: Option<bool>,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
711 pub allow_download: Option<bool>,
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
724#[serde(rename_all = "camelCase")]
725pub struct ShareStateResponse {
726 pub visibility: ShareVisibility,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub token: Option<String>,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub share_url: Option<String>,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub shared_at: Option<String>,
735 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub invited_emails: Option<Vec<String>>,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub party_masking: Option<PartyMasking>,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub show_transcript: Option<bool>,
745 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub show_audio: Option<bool>,
747 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub allow_download: Option<bool>,
751}
752
753impl Client {
754 pub async fn share_recording(
762 &self,
763 req: &ShareRecordingRequest,
764 ) -> Result<ShareRecordingResponse> {
765 if req.recording_source_id.is_empty() {
766 return Err(Error::BadRequest(
767 "recording_source_id must not be empty".into(),
768 ));
769 }
770 let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
771 self.post_json::<ShareRecordingResponse, _>(&path, req)
772 .await
773 }
774
775 pub async fn get_recording_share(
781 &self,
782 recording_source_id: &str,
783 ) -> Result<ShareStateResponse> {
784 if recording_source_id.is_empty() {
785 return Err(Error::BadRequest(
786 "recording_source_id must not be empty".into(),
787 ));
788 }
789 let path = format!("/api/voice/recordings/{recording_source_id}/share");
790 self.get_json::<ShareStateResponse>(&path).await
791 }
792
793 pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
796 if recording_source_id.is_empty() {
797 return Err(Error::BadRequest(
798 "recording_source_id must not be empty".into(),
799 ));
800 }
801 let path = format!("/api/voice/recordings/{recording_source_id}/share");
802 self.delete(&path).await
803 }
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 #[test]
811 fn share_visibility_types_are_reachable_from_the_crate_root() {
812 #[allow(dead_code)]
819 fn _reachable() {
820 let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
821 let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
822 let _: fn(&crate::ShareRecordingRequest) = |_| {};
823 let _: fn(&crate::ShareRecordingResponse) = |_| {};
824 }
825 }
826
827 #[test]
828 fn record_serializes_with_camel_case_keys() {
829 let r = VoiceCallRecord {
830 source_id: "11111111-1111-4111-8111-111111111111".into(),
831 account_id: "22222222-2222-4222-8222-222222222222".into(),
832 direction: VoiceCallDirection::Inbound,
833 party: "+14155550123".into(),
834 ring_at: "2026-05-16T10:00:00Z".into(),
835 answer_at: Some("2026-05-16T10:00:05Z".into()),
836 end_at: "2026-05-16T10:01:00Z".into(),
837 duration_ms: Some(55_000),
838 disposition: VoiceCallDisposition::Answered,
839 end_reason: VoiceCallEndReason::HangupRemote,
840 error: None,
841 share_visibility: None,
842 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
843 };
844 let s = serde_json::to_string(&r).unwrap();
845 assert!(s.contains("\"sourceId\":"), "{s}");
846 assert!(s.contains("\"accountId\":"), "{s}");
847 assert!(s.contains("\"ringAt\":"), "{s}");
848 assert!(s.contains("\"endAt\":"), "{s}");
849 assert!(s.contains("\"durationMs\":55000"), "{s}");
850 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
852 assert!(
856 s.contains("\"schemaVersion\":1"),
857 "schemaVersion should flatten: {s}"
858 );
859 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
862 }
863
864 #[test]
865 fn record_round_trips_optional_fields() {
866 let raw = r#"{
868 "sourceId": "a",
869 "accountId": "b",
870 "direction": "inbound",
871 "party": "anonymous",
872 "ringAt": "2026-05-16T10:00:00Z",
873 "endAt": "2026-05-16T10:00:30Z",
874 "disposition": "missed",
875 "endReason": "missed"
876 }"#;
877 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
878 assert!(parsed.answer_at.is_none());
879 assert!(parsed.duration_ms.is_none());
880 assert!(parsed.error.is_none());
881 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
882 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
883 }
884
885 #[test]
886 fn query_omits_unset_fields() {
887 let q = VoiceCallsQuery::default();
888 let s = serde_json::to_string(&q).unwrap();
889 assert_eq!(
891 s, "{}",
892 "default query should serialize to empty object: {s}"
893 );
894 }
895
896 #[test]
897 fn enum_round_trip_via_json() {
898 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
902 let s = serde_json::to_string(&d).unwrap();
903 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
904 assert_eq!(d, back);
905 }
906 for d in [
907 VoiceCallDisposition::Answered,
908 VoiceCallDisposition::Missed,
909 VoiceCallDisposition::Rejected,
910 VoiceCallDisposition::Cancelled,
911 VoiceCallDisposition::Failed,
912 ] {
913 let s = serde_json::to_string(&d).unwrap();
914 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
915 assert_eq!(d, back);
916 }
917 for r in [
918 VoiceCallEndReason::HangupLocal,
919 VoiceCallEndReason::HangupRemote,
920 VoiceCallEndReason::RejectedLocal,
921 VoiceCallEndReason::RejectedRemote,
922 VoiceCallEndReason::Missed,
923 VoiceCallEndReason::CancelledLocal,
924 VoiceCallEndReason::ConnectionLost,
925 VoiceCallEndReason::Failed,
926 ] {
927 let s = serde_json::to_string(&r).unwrap();
928 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
929 assert_eq!(r, back);
930 }
931 }
932
933 #[test]
934 fn connection_lost_pins_its_wire_string() {
935 let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
939 assert_eq!(s, "\"connection_lost\"");
940 }
941
942 #[test]
943 fn voice_calls_marker_resource_is_calls() {
944 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
945 }
946
947 #[test]
948 fn record_accepts_unknown_extras_for_forward_compat() {
949 let raw = r#"{
955 "sourceId": "a",
956 "accountId": "b",
957 "direction": "inbound",
958 "party": "anon",
959 "ringAt": "2026-05-16T10:00:00Z",
960 "endAt": "2026-05-16T10:00:30Z",
961 "disposition": "answered",
962 "endReason": "hangup_remote",
963 "schemaVersion": 2,
964 "extras": { "notes": "from staging build" }
965 }"#;
966 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
967 assert_eq!(parsed.envelope.schema_version, Some(2));
968 let extras = parsed.envelope.extras.as_ref().expect("extras present");
969 assert_eq!(extras["notes"], "from staging build");
970 }
971
972 #[test]
973 fn call_record_parses_share_visibility_from_list_response() {
974 let raw = r#"{
977 "sourceId": "a",
978 "accountId": "b",
979 "direction": "outbound",
980 "party": "+14155550123",
981 "ringAt": "2026-05-16T10:00:00Z",
982 "endAt": "2026-05-16T10:00:30Z",
983 "disposition": "answered",
984 "endReason": "hangup_remote",
985 "shareVisibility": "public"
986 }"#;
987 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
988 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
989
990 let restricted = raw.replace("public", "restricted");
991 let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
992 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
993 }
994
995 #[test]
996 fn call_record_unshared_has_no_share_visibility() {
997 let base = r#"{
1000 "sourceId": "a",
1001 "accountId": "b",
1002 "direction": "inbound",
1003 "party": "anon",
1004 "ringAt": "2026-05-16T10:00:00Z",
1005 "endAt": "2026-05-16T10:00:30Z",
1006 "disposition": "missed",
1007 "endReason": "missed"
1008 }"#;
1009 let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
1010 assert_eq!(parsed.share_visibility, None);
1011
1012 let with_null = base.replace(
1013 r#""endReason": "missed""#,
1014 r#""endReason": "missed", "shareVisibility": null"#,
1015 );
1016 let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1017 assert_eq!(parsed.share_visibility, None);
1018 }
1019
1020 #[test]
1021 fn synced_call_omits_share_visibility() {
1022 let raw = r#"{
1026 "sourceId": "a",
1027 "accountId": "b",
1028 "direction": "inbound",
1029 "party": "anon",
1030 "ringAt": "2026-05-16T10:00:00Z",
1031 "endAt": "2026-05-16T10:00:30Z",
1032 "disposition": "answered",
1033 "endReason": "hangup_remote"
1034 }"#;
1035 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1036 assert_eq!(parsed.share_visibility, None);
1037 let s = serde_json::to_string(&parsed).unwrap();
1038 assert!(
1039 !s.contains("shareVisibility"),
1040 "sync payload leaked share_visibility: {s}"
1041 );
1042 }
1043
1044 #[test]
1045 fn recording_marker_resource_is_recordings() {
1046 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1049 }
1050
1051 #[test]
1052 fn recording_record_serializes_with_camel_case_and_envelope() {
1053 let r = VoiceRecordingRecord {
1054 source_id: "11111111-1111-4111-8111-111111111111".into(),
1055 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1056 size_bytes: 44 + 64_000,
1057 duration_ms: 2_000,
1058 sample_rate: 8_000,
1059 channels: 2,
1060 created_at: "2026-05-16T10:01:05Z".into(),
1061 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1062 };
1063 let s = serde_json::to_string(&r).unwrap();
1064 assert!(s.contains("\"sourceId\":"), "{s}");
1067 assert!(s.contains("\"callSourceId\":"), "{s}");
1068 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1069 assert!(s.contains("\"durationMs\":2000"), "{s}");
1070 assert!(s.contains("\"sampleRate\":8000"), "{s}");
1071 assert!(s.contains("\"channels\":2"), "{s}");
1072 assert!(s.contains("\"createdAt\":"), "{s}");
1073 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1075 }
1076
1077 #[test]
1078 fn recordings_sync_response_round_trips() {
1079 let raw = r#"{
1084 "accepted": 2,
1085 "skipped": 0,
1086 "items": [
1087 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1088 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1089 ]
1090 }"#;
1091 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1092 assert_eq!(parsed.accepted, 2);
1093 assert_eq!(parsed.items.len(), 2);
1094 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1095 assert!(!parsed.items[0].bytes_uploaded);
1096 assert!(parsed.items[1].bytes_uploaded);
1097 }
1098
1099 #[test]
1100 fn install_heartbeat_request_serializes_with_camel_case_keys() {
1101 let req = InstallHeartbeatRequest {
1102 install_id: "11111111-1111-4111-8111-111111111111".into(),
1103 app_version: "0.0.21".into(),
1104 os: "macos".into(),
1105 os_version: Some("15.5.0".into()),
1106 arch: Some("aarch64".into()),
1107 locale: Some("en-NZ".into()),
1108 };
1109 let s = serde_json::to_string(&req).unwrap();
1110 assert!(s.contains("\"installId\":"), "{s}");
1111 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1112 assert!(s.contains("\"os\":\"macos\""), "{s}");
1113 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1114 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1115 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1116 }
1117
1118 #[test]
1119 fn install_heartbeat_request_omits_absent_optional_fields() {
1120 let req = InstallHeartbeatRequest {
1125 install_id: "x".into(),
1126 app_version: "0.0.21".into(),
1127 os: "linux".into(),
1128 os_version: None,
1129 arch: None,
1130 locale: None,
1131 };
1132 let s = serde_json::to_string(&req).unwrap();
1133 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1134 assert!(!s.contains("arch"), "arch should be omitted: {s}");
1135 assert!(!s.contains("locale"), "locale should be omitted: {s}");
1136 }
1137
1138 #[test]
1139 fn install_heartbeat_response_parses_platform_shape() {
1140 let raw = r#"{
1141 "id": "abc-123",
1142 "installId": "11111111-1111-4111-8111-111111111111",
1143 "appVersion": "0.0.21",
1144 "os": "macos",
1145 "osVersion": "15.5.0",
1146 "arch": "aarch64",
1147 "locale": null,
1148 "firstSeenAt": "2026-05-31T10:00:00.000Z",
1149 "lastSeenAt": "2026-05-31T10:00:00.000Z"
1150 }"#;
1151 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1152 assert_eq!(parsed.id, "abc-123");
1153 assert_eq!(parsed.app_version, "0.0.21");
1154 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1155 assert!(parsed.locale.is_none());
1156 }
1157
1158 #[test]
1159 fn system_info_detect_fills_os_and_arch() {
1160 let sys = SystemInfo::detect();
1164 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1165 assert!(
1166 !sys.arch.is_empty(),
1167 "arch should be a non-empty target string"
1168 );
1169 }
1170
1171 #[test]
1172 fn transcripts_marker_resource_is_transcripts() {
1173 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1174 }
1175
1176 #[test]
1177 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1178 let r = VoiceTranscriptRecord {
1179 source_id: "1".into(),
1180 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1181 channel: VoiceTranscriptChannel::Remote,
1182 ts_ms: 100,
1183 end_ms: 1_500,
1184 text: "hello".into(),
1185 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1186 };
1187 let s = serde_json::to_string(&r).unwrap();
1188 assert!(s.contains("\"sourceId\":"), "{s}");
1189 assert!(s.contains("\"callSourceId\":"), "{s}");
1190 assert!(s.contains("\"channel\":\"remote\""), "{s}");
1193 assert!(s.contains("\"tsMs\":100"), "{s}");
1194 assert!(s.contains("\"endMs\":1500"), "{s}");
1195 assert!(s.contains("\"text\":\"hello\""), "{s}");
1196 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1197 }
1198
1199 #[test]
1200 fn share_visibility_pins_its_wire_strings() {
1201 assert_eq!(
1204 serde_json::to_string(&ShareVisibility::Private).unwrap(),
1205 "\"private\""
1206 );
1207 assert_eq!(
1208 serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1209 "\"restricted\""
1210 );
1211 assert_eq!(
1212 serde_json::to_string(&ShareVisibility::Public).unwrap(),
1213 "\"public\""
1214 );
1215 for v in [
1216 ShareVisibility::Private,
1217 ShareVisibility::Restricted,
1218 ShareVisibility::Public,
1219 ] {
1220 let s = serde_json::to_string(&v).unwrap();
1221 let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1222 assert_eq!(v, back);
1223 }
1224 }
1225
1226 #[test]
1227 fn share_request_serializes_with_camel_case_and_omits_unset() {
1228 let req = ShareRecordingRequest {
1229 recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1230 visibility: ShareVisibility::Public,
1231 invited_emails: None,
1232 party_masking: None,
1233 show_transcript: None,
1234 show_audio: None,
1235 allow_download: None,
1236 password: None,
1237 expires_at: None,
1238 };
1239 let s = serde_json::to_string(&req).unwrap();
1240 assert!(s.contains("\"recordingSourceId\":"), "{s}");
1241 assert!(s.contains("\"visibility\":\"public\""), "{s}");
1242 assert!(!s.contains("invitedEmails"), "{s}");
1246 assert!(!s.contains("partyMasking"), "{s}");
1247 assert!(!s.contains("showTranscript"), "{s}");
1248 assert!(!s.contains("showAudio"), "{s}");
1249 assert!(!s.contains("allowDownload"), "{s}");
1250 assert!(!s.contains("password"), "{s}");
1251 assert!(!s.contains("expiresAt"), "{s}");
1252 }
1253
1254 #[test]
1255 fn share_request_serializes_visibility_controls_camel_case() {
1256 let req = ShareRecordingRequest {
1257 recording_source_id: "a".into(),
1258 visibility: ShareVisibility::Public,
1259 invited_emails: None,
1260 party_masking: Some(PartyMasking::Partial),
1261 show_transcript: Some(false),
1262 show_audio: Some(true),
1263 allow_download: Some(true),
1264 password: None,
1265 expires_at: None,
1266 };
1267 let s = serde_json::to_string(&req).unwrap();
1268 assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1269 assert!(s.contains("\"showTranscript\":false"), "{s}");
1270 assert!(s.contains("\"showAudio\":true"), "{s}");
1271 assert!(s.contains("\"allowDownload\":true"), "{s}");
1272 }
1273
1274 #[test]
1275 fn share_request_carries_invited_emails_for_restricted() {
1276 let req = ShareRecordingRequest {
1277 recording_source_id: "a".into(),
1278 visibility: ShareVisibility::Restricted,
1279 invited_emails: Some(vec!["alex@example.com".into()]),
1280 party_masking: None,
1281 show_transcript: None,
1282 show_audio: None,
1283 allow_download: None,
1284 password: None,
1285 expires_at: None,
1286 };
1287 let s = serde_json::to_string(&req).unwrap();
1288 assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1289 assert!(
1290 s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1291 "{s}"
1292 );
1293 }
1294
1295 #[test]
1296 fn share_response_parses_platform_shape() {
1297 let raw = r#"{
1298 "visibility": "public",
1299 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1300 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1301 "sharedAt": "2026-06-19T10:00:00.000Z"
1302 }"#;
1303 let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1304 assert_eq!(parsed.visibility, ShareVisibility::Public);
1305 assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1306 assert!(parsed.share_url.ends_with(&parsed.token));
1307 }
1308
1309 #[test]
1310 fn share_state_parses_restricted_with_invited_emails() {
1311 let raw = r#"{
1314 "visibility": "restricted",
1315 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1316 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1317 "sharedAt": "2026-06-19T10:00:00.000Z",
1318 "invitedEmails": ["bob@example.com", "carol@example.com"],
1319 "partyMasking": "full",
1320 "showTranscript": true,
1321 "showAudio": false,
1322 "allowDownload": false
1323 }"#;
1324 let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1325 assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1326 assert_eq!(
1327 parsed.invited_emails.as_deref(),
1328 Some(
1329 [
1330 "bob@example.com".to_string(),
1331 "carol@example.com".to_string()
1332 ]
1333 .as_slice()
1334 )
1335 );
1336 assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1338 assert_eq!(parsed.show_transcript, Some(true));
1339 assert_eq!(parsed.show_audio, Some(false));
1340 assert_eq!(parsed.allow_download, Some(false));
1342 }
1343
1344 #[test]
1345 fn share_state_parses_private_with_fields_absent() {
1346 let parsed: ShareStateResponse =
1349 serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1350 assert_eq!(parsed.visibility, ShareVisibility::Private);
1351 assert!(parsed.token.is_none());
1352 assert!(parsed.share_url.is_none());
1353 assert!(parsed.shared_at.is_none());
1354 assert!(parsed.invited_emails.is_none());
1355 }
1356
1357 #[test]
1358 fn share_request_rejects_empty_source_id_before_hitting_network() {
1359 let req = ShareRecordingRequest {
1362 recording_source_id: String::new(),
1363 visibility: ShareVisibility::Private,
1364 invited_emails: None,
1365 party_masking: None,
1366 show_transcript: None,
1367 show_audio: None,
1368 allow_download: None,
1369 password: None,
1370 expires_at: None,
1371 };
1372 assert!(req.recording_source_id.is_empty());
1376 }
1377
1378 fn sample_account() -> VoiceAccountRecord {
1381 VoiceAccountRecord {
1382 source_id: "11111111-1111-4111-8111-111111111111".into(),
1383 enabled: true,
1384 display_name: "Work line".into(),
1385 username: "alice".into(),
1386 domain: "sip.example.com".into(),
1387 auth_username: Some("alice-auth".into()),
1388 server: Some("sip.example.com".into()),
1389 port: Some(5060),
1390 transport: VoiceTransport::Udp,
1391 register_expires: 60,
1392 keepalive_secs: Some(50),
1393 disclosure_enabled: true,
1394 updated_at: "2026-06-20T10:00:00Z".into(),
1395 deleted_at: None,
1396 envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1397 }
1398 }
1399
1400 #[test]
1401 fn accounts_marker_resource_is_accounts() {
1402 assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1405 }
1406
1407 #[test]
1408 fn account_record_serializes_with_camel_case_and_envelope() {
1409 let s = serde_json::to_string(&sample_account()).unwrap();
1410 assert!(s.contains("\"sourceId\":"), "{s}");
1413 assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1414 assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1415 assert!(s.contains("\"registerExpires\":60"), "{s}");
1416 assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1417 assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1418 assert!(s.contains("\"transport\":\"udp\""), "{s}");
1419 assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1420 assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1422 assert!(!s.contains("password"), "no password field: {s}");
1424 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1426 }
1427
1428 #[test]
1429 fn account_tombstone_serializes_deleted_at() {
1430 let mut r = sample_account();
1433 r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1434 let s = serde_json::to_string(&r).unwrap();
1435 assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1436 }
1437
1438 #[test]
1439 fn account_record_round_trips_optional_fields() {
1440 let raw = r#"{
1443 "sourceId": "a",
1444 "enabled": false,
1445 "displayName": "Cheap trunk",
1446 "username": "u",
1447 "domain": "d",
1448 "transport": "tcp",
1449 "registerExpires": 120,
1450 "disclosureEnabled": false,
1451 "updatedAt": "2026-06-20T10:00:00Z"
1452 }"#;
1453 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1454 assert!(!parsed.enabled);
1455 assert!(parsed.auth_username.is_none());
1456 assert!(parsed.server.is_none());
1457 assert!(parsed.port.is_none());
1458 assert!(parsed.keepalive_secs.is_none());
1459 assert!(parsed.deleted_at.is_none());
1460 assert_eq!(parsed.transport, VoiceTransport::Tcp);
1461 assert_eq!(parsed.register_expires, 120);
1462 }
1463
1464 #[test]
1465 fn voice_transport_round_trips_via_json() {
1466 for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1467 let s = serde_json::to_string(&t).unwrap();
1468 let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1469 assert_eq!(t, back);
1470 }
1471 assert_eq!(
1474 serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1475 "\"udp\""
1476 );
1477 assert_eq!(
1478 serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1479 "\"tcp\""
1480 );
1481 }
1482
1483 #[test]
1484 fn accounts_query_omits_unset_and_serializes_include_deleted() {
1485 let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1486 assert_eq!(empty, "{}", "default query should be empty: {empty}");
1487 let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1488 include_deleted: Some(true),
1489 })
1490 .unwrap();
1491 assert!(
1492 with_deleted.contains("\"includeDeleted\":true"),
1493 "{with_deleted}"
1494 );
1495 }
1496
1497 #[test]
1498 fn account_record_accepts_unknown_extras_for_forward_compat() {
1499 let raw = r#"{
1502 "sourceId": "a",
1503 "enabled": true,
1504 "displayName": "x",
1505 "username": "u",
1506 "domain": "d",
1507 "transport": "udp",
1508 "registerExpires": 60,
1509 "disclosureEnabled": true,
1510 "updatedAt": "2026-06-20T10:00:00Z",
1511 "schemaVersion": 2,
1512 "extras": { "ringtone": "classic" }
1513 }"#;
1514 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1515 assert_eq!(parsed.envelope.schema_version, Some(2));
1516 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1517 assert_eq!(extras["ringtone"], "classic");
1518 }
1519}