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, Eq)]
480pub struct SystemInfo {
481 pub os: String,
483 pub os_version: Option<String>,
486 pub arch: String,
488 pub locale: Option<String>,
491}
492
493impl SystemInfo {
494 pub fn detect() -> Self {
497 let os_version = match os_info::get().version() {
498 os_info::Version::Unknown => None,
499 v => Some(v.to_string()),
500 };
501 SystemInfo {
502 os: std::env::consts::OS.to_string(),
503 os_version,
504 arch: std::env::consts::ARCH.to_string(),
505 locale: sys_locale::get_locale(),
506 }
507 }
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
514#[serde(rename_all = "camelCase")]
515pub struct InstallHeartbeatRequest {
516 pub install_id: String,
518 pub app_version: String,
521 pub os: String,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub os_version: Option<String>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub arch: Option<String>,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub locale: Option<String>,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
532#[serde(rename_all = "camelCase")]
533pub struct InstallHeartbeatResponse {
534 pub id: String,
535 pub install_id: String,
536 pub app_version: String,
537 pub os: String,
538 pub os_version: Option<String>,
539 pub arch: Option<String>,
540 pub locale: Option<String>,
541 pub first_seen_at: String,
542 pub last_seen_at: String,
543}
544
545impl Client {
546 pub async fn install_heartbeat(
563 base_url: &str,
564 install_id: &str,
565 app_version: &str,
566 cred: &ReleaseCredential,
567 ) -> Result<InstallHeartbeatResponse> {
568 let sys = SystemInfo::detect();
569 let body = InstallHeartbeatRequest {
570 install_id: install_id.to_string(),
571 app_version: app_version.to_string(),
572 os: sys.os,
573 os_version: sys.os_version,
574 arch: Some(sys.arch),
575 locale: sys.locale,
576 };
577 Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
578 base_url,
579 "/api/voice/installs/heartbeat",
580 &body,
581 cred,
582 )
583 .await
584 }
585}
586
587impl Client {
602 pub async fn sync_recordings(
610 &self,
611 items: &[VoiceRecordingRecord],
612 ) -> Result<VoiceRecordingsSyncResponse> {
613 let stamped = stamp_schema_version::<VoiceRecordings>(items);
614 let body = SyncRequest { items: stamped };
615 self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
616 .await
617 }
618
619 pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
630 if source_id.is_empty() {
631 return Err(Error::BadRequest("source_id must not be empty".into()));
632 }
633 let path = format!("/api/voice/recordings/{source_id}/bytes");
634 self.put_raw_bytes(&path, "audio/wav", bytes).await
635 }
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
658#[serde(rename_all = "snake_case")]
659pub enum ShareVisibility {
660 Private,
661 Restricted,
662 Public,
663}
664
665#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
677#[serde(rename_all = "snake_case")]
678pub enum PartyMasking {
679 Full,
680 Partial,
681 None,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize)]
688#[serde(rename_all = "camelCase")]
689pub struct ShareRecordingRequest {
690 pub recording_source_id: String,
693 pub visibility: ShareVisibility,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
697 pub invited_emails: Option<Vec<String>>,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub party_masking: Option<PartyMasking>,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
708 pub show_transcript: Option<bool>,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub show_audio: Option<bool>,
711 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub allow_download: Option<bool>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub default_mute_local: Option<bool>,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub default_mute_remote: Option<bool>,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub password: Option<String>,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub expires_at: Option<String>,
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
743#[serde(rename_all = "camelCase")]
744pub struct ShareRecordingResponse {
745 pub visibility: ShareVisibility,
746 pub token: String,
747 pub share_url: String,
748 pub shared_at: String,
750 #[serde(default, skip_serializing_if = "Option::is_none")]
755 pub party_masking: Option<PartyMasking>,
756 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub show_transcript: Option<bool>,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub show_audio: Option<bool>,
760 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub allow_download: Option<bool>,
765 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub default_mute_local: Option<bool>,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub default_mute_remote: Option<bool>,
772}
773
774#[derive(Debug, Clone, Serialize, Deserialize)]
784#[serde(rename_all = "camelCase")]
785pub struct ShareStateResponse {
786 pub visibility: ShareVisibility,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub token: Option<String>,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub share_url: Option<String>,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub shared_at: Option<String>,
795 #[serde(default, skip_serializing_if = "Option::is_none")]
798 pub invited_emails: Option<Vec<String>>,
799 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub party_masking: Option<PartyMasking>,
803 #[serde(default, skip_serializing_if = "Option::is_none")]
804 pub show_transcript: Option<bool>,
805 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub show_audio: Option<bool>,
807 #[serde(default, skip_serializing_if = "Option::is_none")]
810 pub allow_download: Option<bool>,
811 #[serde(default, skip_serializing_if = "Option::is_none")]
814 pub default_mute_local: Option<bool>,
815 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub default_mute_remote: Option<bool>,
817}
818
819impl Client {
820 pub async fn share_recording(
828 &self,
829 req: &ShareRecordingRequest,
830 ) -> Result<ShareRecordingResponse> {
831 if req.recording_source_id.is_empty() {
832 return Err(Error::BadRequest(
833 "recording_source_id must not be empty".into(),
834 ));
835 }
836 let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
837 self.post_json::<ShareRecordingResponse, _>(&path, req)
838 .await
839 }
840
841 pub async fn get_recording_share(
847 &self,
848 recording_source_id: &str,
849 ) -> Result<ShareStateResponse> {
850 if recording_source_id.is_empty() {
851 return Err(Error::BadRequest(
852 "recording_source_id must not be empty".into(),
853 ));
854 }
855 let path = format!("/api/voice/recordings/{recording_source_id}/share");
856 self.get_json::<ShareStateResponse>(&path).await
857 }
858
859 pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
862 if recording_source_id.is_empty() {
863 return Err(Error::BadRequest(
864 "recording_source_id must not be empty".into(),
865 ));
866 }
867 let path = format!("/api/voice/recordings/{recording_source_id}/share");
868 self.delete(&path).await
869 }
870}
871
872#[cfg(test)]
873mod tests {
874 use super::*;
875
876 #[test]
877 fn share_visibility_types_are_reachable_from_the_crate_root() {
878 #[allow(dead_code)]
885 fn _reachable() {
886 let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
887 let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
888 let _: fn(&crate::ShareRecordingRequest) = |_| {};
889 let _: fn(&crate::ShareRecordingResponse) = |_| {};
890 }
891 }
892
893 #[test]
894 fn record_serializes_with_camel_case_keys() {
895 let r = VoiceCallRecord {
896 source_id: "11111111-1111-4111-8111-111111111111".into(),
897 account_id: "22222222-2222-4222-8222-222222222222".into(),
898 direction: VoiceCallDirection::Inbound,
899 party: "+14155550123".into(),
900 ring_at: "2026-05-16T10:00:00Z".into(),
901 answer_at: Some("2026-05-16T10:00:05Z".into()),
902 end_at: "2026-05-16T10:01:00Z".into(),
903 duration_ms: Some(55_000),
904 disposition: VoiceCallDisposition::Answered,
905 end_reason: VoiceCallEndReason::HangupRemote,
906 error: None,
907 share_visibility: None,
908 transfer_target: None,
909 codec: None,
910 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
911 };
912 let s = serde_json::to_string(&r).unwrap();
913 assert!(s.contains("\"sourceId\":"), "{s}");
914 assert!(s.contains("\"accountId\":"), "{s}");
915 assert!(s.contains("\"ringAt\":"), "{s}");
916 assert!(s.contains("\"endAt\":"), "{s}");
917 assert!(s.contains("\"durationMs\":55000"), "{s}");
918 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
920 assert!(
923 !s.contains("\"transferTarget\""),
924 "transferTarget should be omitted: {s}"
925 );
926 assert!(!s.contains("\"codec\""), "codec should be omitted: {s}");
929 assert!(
933 s.contains("\"schemaVersion\":1"),
934 "schemaVersion should flatten: {s}"
935 );
936 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
939 }
940
941 #[test]
942 fn record_round_trips_optional_fields() {
943 let raw = r#"{
945 "sourceId": "a",
946 "accountId": "b",
947 "direction": "inbound",
948 "party": "anonymous",
949 "ringAt": "2026-05-16T10:00:00Z",
950 "endAt": "2026-05-16T10:00:30Z",
951 "disposition": "missed",
952 "endReason": "missed"
953 }"#;
954 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
955 assert!(parsed.answer_at.is_none());
956 assert!(parsed.duration_ms.is_none());
957 assert!(parsed.error.is_none());
958 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
959 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
960 }
961
962 #[test]
963 fn query_omits_unset_fields() {
964 let q = VoiceCallsQuery::default();
965 let s = serde_json::to_string(&q).unwrap();
966 assert_eq!(
968 s, "{}",
969 "default query should serialize to empty object: {s}"
970 );
971 }
972
973 #[test]
974 fn enum_round_trip_via_json() {
975 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
979 let s = serde_json::to_string(&d).unwrap();
980 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
981 assert_eq!(d, back);
982 }
983 for d in [
984 VoiceCallDisposition::Answered,
985 VoiceCallDisposition::Missed,
986 VoiceCallDisposition::Rejected,
987 VoiceCallDisposition::Cancelled,
988 VoiceCallDisposition::Failed,
989 ] {
990 let s = serde_json::to_string(&d).unwrap();
991 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
992 assert_eq!(d, back);
993 }
994 for r in [
995 VoiceCallEndReason::HangupLocal,
996 VoiceCallEndReason::HangupRemote,
997 VoiceCallEndReason::RejectedLocal,
998 VoiceCallEndReason::RejectedRemote,
999 VoiceCallEndReason::Missed,
1000 VoiceCallEndReason::CancelledLocal,
1001 VoiceCallEndReason::TransferredLocal,
1002 VoiceCallEndReason::ConnectionLost,
1003 VoiceCallEndReason::Failed,
1004 ] {
1005 let s = serde_json::to_string(&r).unwrap();
1006 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
1007 assert_eq!(r, back);
1008 }
1009 }
1010
1011 #[test]
1012 fn connection_lost_pins_its_wire_string() {
1013 let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
1017 assert_eq!(s, "\"connection_lost\"");
1018 }
1019
1020 #[test]
1021 fn transferred_local_pins_its_wire_string() {
1022 let s = serde_json::to_string(&VoiceCallEndReason::TransferredLocal).unwrap();
1026 assert_eq!(s, "\"transferred_local\"");
1027 }
1028
1029 #[test]
1030 fn record_round_trips_transfer_target() {
1031 let raw = r#"{
1035 "sourceId": "a",
1036 "accountId": "b",
1037 "direction": "inbound",
1038 "party": "Alice <sip:alice@example.com>",
1039 "ringAt": "2026-06-28T10:00:00Z",
1040 "answerAt": "2026-06-28T10:00:05Z",
1041 "endAt": "2026-06-28T10:00:30Z",
1042 "durationMs": 25000,
1043 "disposition": "answered",
1044 "endReason": "transferred_local",
1045 "transferTarget": "1002"
1046 }"#;
1047 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1048 assert_eq!(parsed.end_reason, VoiceCallEndReason::TransferredLocal);
1049 assert_eq!(parsed.transfer_target.as_deref(), Some("1002"));
1050 let s = serde_json::to_string(&parsed).unwrap();
1052 assert!(s.contains("\"transferTarget\":\"1002\""), "{s}");
1053 }
1054
1055 #[test]
1056 fn codec_pins_its_wire_strings() {
1057 for (codec, wire) in [
1062 (VoiceCallCodec::Opus, "\"opus\""),
1063 (VoiceCallCodec::Pcmu, "\"pcmu\""),
1064 (VoiceCallCodec::Pcma, "\"pcma\""),
1065 ] {
1066 assert_eq!(serde_json::to_string(&codec).unwrap(), wire);
1067 let back: VoiceCallCodec = serde_json::from_str(wire).unwrap();
1068 assert_eq!(back, codec);
1069 }
1070 }
1071
1072 #[test]
1073 fn record_round_trips_codec() {
1074 let raw = r#"{
1079 "sourceId": "a",
1080 "accountId": "b",
1081 "direction": "inbound",
1082 "party": "Alice <sip:alice@example.com>",
1083 "ringAt": "2026-07-03T10:00:00Z",
1084 "answerAt": "2026-07-03T10:00:05Z",
1085 "endAt": "2026-07-03T10:00:30Z",
1086 "durationMs": 25000,
1087 "disposition": "answered",
1088 "endReason": "hangup_remote",
1089 "codec": "opus"
1090 }"#;
1091 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1092 assert_eq!(parsed.codec, Some(VoiceCallCodec::Opus));
1093 let s = serde_json::to_string(&parsed).unwrap();
1095 assert!(s.contains("\"codec\":\"opus\""), "{s}");
1096
1097 let legacy = raw.replace(",\n \"codec\": \"opus\"", "");
1099 let parsed: VoiceCallRecord = serde_json::from_str(&legacy).unwrap();
1100 assert_eq!(parsed.codec, None);
1101 }
1102
1103 #[test]
1104 fn voice_calls_marker_resource_is_calls() {
1105 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
1106 }
1107
1108 #[test]
1109 fn record_accepts_unknown_extras_for_forward_compat() {
1110 let raw = r#"{
1116 "sourceId": "a",
1117 "accountId": "b",
1118 "direction": "inbound",
1119 "party": "anon",
1120 "ringAt": "2026-05-16T10:00:00Z",
1121 "endAt": "2026-05-16T10:00:30Z",
1122 "disposition": "answered",
1123 "endReason": "hangup_remote",
1124 "schemaVersion": 2,
1125 "extras": { "notes": "from staging build" }
1126 }"#;
1127 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1128 assert_eq!(parsed.envelope.schema_version, Some(2));
1129 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1130 assert_eq!(extras["notes"], "from staging build");
1131 }
1132
1133 #[test]
1134 fn call_record_parses_share_visibility_from_list_response() {
1135 let raw = r#"{
1138 "sourceId": "a",
1139 "accountId": "b",
1140 "direction": "outbound",
1141 "party": "+14155550123",
1142 "ringAt": "2026-05-16T10:00:00Z",
1143 "endAt": "2026-05-16T10:00:30Z",
1144 "disposition": "answered",
1145 "endReason": "hangup_remote",
1146 "shareVisibility": "public"
1147 }"#;
1148 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1149 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
1150
1151 let restricted = raw.replace("public", "restricted");
1152 let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
1153 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
1154 }
1155
1156 #[test]
1157 fn call_record_unshared_has_no_share_visibility() {
1158 let base = r#"{
1161 "sourceId": "a",
1162 "accountId": "b",
1163 "direction": "inbound",
1164 "party": "anon",
1165 "ringAt": "2026-05-16T10:00:00Z",
1166 "endAt": "2026-05-16T10:00:30Z",
1167 "disposition": "missed",
1168 "endReason": "missed"
1169 }"#;
1170 let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
1171 assert_eq!(parsed.share_visibility, None);
1172
1173 let with_null = base.replace(
1174 r#""endReason": "missed""#,
1175 r#""endReason": "missed", "shareVisibility": null"#,
1176 );
1177 let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1178 assert_eq!(parsed.share_visibility, None);
1179 }
1180
1181 #[test]
1182 fn synced_call_omits_share_visibility() {
1183 let raw = r#"{
1187 "sourceId": "a",
1188 "accountId": "b",
1189 "direction": "inbound",
1190 "party": "anon",
1191 "ringAt": "2026-05-16T10:00:00Z",
1192 "endAt": "2026-05-16T10:00:30Z",
1193 "disposition": "answered",
1194 "endReason": "hangup_remote"
1195 }"#;
1196 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1197 assert_eq!(parsed.share_visibility, None);
1198 let s = serde_json::to_string(&parsed).unwrap();
1199 assert!(
1200 !s.contains("shareVisibility"),
1201 "sync payload leaked share_visibility: {s}"
1202 );
1203 }
1204
1205 #[test]
1206 fn recording_marker_resource_is_recordings() {
1207 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1210 }
1211
1212 #[test]
1213 fn recording_record_serializes_with_camel_case_and_envelope() {
1214 let r = VoiceRecordingRecord {
1215 source_id: "11111111-1111-4111-8111-111111111111".into(),
1216 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1217 size_bytes: 44 + 64_000,
1218 duration_ms: 2_000,
1219 sample_rate: 8_000,
1220 channels: 2,
1221 created_at: "2026-05-16T10:01:05Z".into(),
1222 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1223 };
1224 let s = serde_json::to_string(&r).unwrap();
1225 assert!(s.contains("\"sourceId\":"), "{s}");
1228 assert!(s.contains("\"callSourceId\":"), "{s}");
1229 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1230 assert!(s.contains("\"durationMs\":2000"), "{s}");
1231 assert!(s.contains("\"sampleRate\":8000"), "{s}");
1232 assert!(s.contains("\"channels\":2"), "{s}");
1233 assert!(s.contains("\"createdAt\":"), "{s}");
1234 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1236 }
1237
1238 #[test]
1239 fn recordings_sync_response_round_trips() {
1240 let raw = r#"{
1245 "accepted": 2,
1246 "skipped": 0,
1247 "items": [
1248 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1249 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1250 ]
1251 }"#;
1252 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1253 assert_eq!(parsed.accepted, 2);
1254 assert_eq!(parsed.items.len(), 2);
1255 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1256 assert!(!parsed.items[0].bytes_uploaded);
1257 assert!(parsed.items[1].bytes_uploaded);
1258 }
1259
1260 #[test]
1261 fn install_heartbeat_request_serializes_with_camel_case_keys() {
1262 let req = InstallHeartbeatRequest {
1263 install_id: "11111111-1111-4111-8111-111111111111".into(),
1264 app_version: "0.0.21".into(),
1265 os: "macos".into(),
1266 os_version: Some("15.5.0".into()),
1267 arch: Some("aarch64".into()),
1268 locale: Some("en-NZ".into()),
1269 };
1270 let s = serde_json::to_string(&req).unwrap();
1271 assert!(s.contains("\"installId\":"), "{s}");
1272 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1273 assert!(s.contains("\"os\":\"macos\""), "{s}");
1274 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1275 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1276 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1277 }
1278
1279 #[test]
1280 fn install_heartbeat_request_omits_absent_optional_fields() {
1281 let req = InstallHeartbeatRequest {
1286 install_id: "x".into(),
1287 app_version: "0.0.21".into(),
1288 os: "linux".into(),
1289 os_version: None,
1290 arch: None,
1291 locale: None,
1292 };
1293 let s = serde_json::to_string(&req).unwrap();
1294 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1295 assert!(!s.contains("arch"), "arch should be omitted: {s}");
1296 assert!(!s.contains("locale"), "locale should be omitted: {s}");
1297 }
1298
1299 #[test]
1300 fn install_heartbeat_response_parses_platform_shape() {
1301 let raw = r#"{
1302 "id": "abc-123",
1303 "installId": "11111111-1111-4111-8111-111111111111",
1304 "appVersion": "0.0.21",
1305 "os": "macos",
1306 "osVersion": "15.5.0",
1307 "arch": "aarch64",
1308 "locale": null,
1309 "firstSeenAt": "2026-05-31T10:00:00.000Z",
1310 "lastSeenAt": "2026-05-31T10:00:00.000Z"
1311 }"#;
1312 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1313 assert_eq!(parsed.id, "abc-123");
1314 assert_eq!(parsed.app_version, "0.0.21");
1315 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1316 assert!(parsed.locale.is_none());
1317 }
1318
1319 #[test]
1320 fn system_info_detect_fills_os_and_arch() {
1321 let sys = SystemInfo::detect();
1325 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1326 assert!(
1327 !sys.arch.is_empty(),
1328 "arch should be a non-empty target string"
1329 );
1330 }
1331
1332 #[test]
1333 fn transcripts_marker_resource_is_transcripts() {
1334 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1335 }
1336
1337 #[test]
1338 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1339 let r = VoiceTranscriptRecord {
1340 source_id: "1".into(),
1341 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1342 channel: VoiceTranscriptChannel::Remote,
1343 ts_ms: 100,
1344 end_ms: 1_500,
1345 text: "hello".into(),
1346 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1347 };
1348 let s = serde_json::to_string(&r).unwrap();
1349 assert!(s.contains("\"sourceId\":"), "{s}");
1350 assert!(s.contains("\"callSourceId\":"), "{s}");
1351 assert!(s.contains("\"channel\":\"remote\""), "{s}");
1354 assert!(s.contains("\"tsMs\":100"), "{s}");
1355 assert!(s.contains("\"endMs\":1500"), "{s}");
1356 assert!(s.contains("\"text\":\"hello\""), "{s}");
1357 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1358 }
1359
1360 #[test]
1361 fn share_visibility_pins_its_wire_strings() {
1362 assert_eq!(
1365 serde_json::to_string(&ShareVisibility::Private).unwrap(),
1366 "\"private\""
1367 );
1368 assert_eq!(
1369 serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1370 "\"restricted\""
1371 );
1372 assert_eq!(
1373 serde_json::to_string(&ShareVisibility::Public).unwrap(),
1374 "\"public\""
1375 );
1376 for v in [
1377 ShareVisibility::Private,
1378 ShareVisibility::Restricted,
1379 ShareVisibility::Public,
1380 ] {
1381 let s = serde_json::to_string(&v).unwrap();
1382 let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1383 assert_eq!(v, back);
1384 }
1385 }
1386
1387 #[test]
1388 fn share_request_serializes_with_camel_case_and_omits_unset() {
1389 let req = ShareRecordingRequest {
1390 recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1391 visibility: ShareVisibility::Public,
1392 invited_emails: None,
1393 party_masking: None,
1394 show_transcript: None,
1395 show_audio: None,
1396 allow_download: None,
1397 default_mute_local: None,
1398 default_mute_remote: None,
1399 password: None,
1400 expires_at: None,
1401 };
1402 let s = serde_json::to_string(&req).unwrap();
1403 assert!(s.contains("\"recordingSourceId\":"), "{s}");
1404 assert!(s.contains("\"visibility\":\"public\""), "{s}");
1405 assert!(!s.contains("invitedEmails"), "{s}");
1409 assert!(!s.contains("partyMasking"), "{s}");
1410 assert!(!s.contains("showTranscript"), "{s}");
1411 assert!(!s.contains("showAudio"), "{s}");
1412 assert!(!s.contains("allowDownload"), "{s}");
1413 assert!(!s.contains("defaultMuteLocal"), "{s}");
1414 assert!(!s.contains("defaultMuteRemote"), "{s}");
1415 assert!(!s.contains("password"), "{s}");
1416 assert!(!s.contains("expiresAt"), "{s}");
1417 }
1418
1419 #[test]
1420 fn share_request_serializes_visibility_controls_camel_case() {
1421 let req = ShareRecordingRequest {
1422 recording_source_id: "a".into(),
1423 visibility: ShareVisibility::Public,
1424 invited_emails: None,
1425 party_masking: Some(PartyMasking::Partial),
1426 show_transcript: Some(false),
1427 show_audio: Some(true),
1428 allow_download: Some(true),
1429 default_mute_local: Some(false),
1430 default_mute_remote: Some(true),
1431 password: None,
1432 expires_at: None,
1433 };
1434 let s = serde_json::to_string(&req).unwrap();
1435 assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1436 assert!(s.contains("\"showTranscript\":false"), "{s}");
1437 assert!(s.contains("\"showAudio\":true"), "{s}");
1438 assert!(s.contains("\"allowDownload\":true"), "{s}");
1439 assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
1442 assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
1443 }
1444
1445 #[test]
1446 fn share_request_carries_invited_emails_for_restricted() {
1447 let req = ShareRecordingRequest {
1448 recording_source_id: "a".into(),
1449 visibility: ShareVisibility::Restricted,
1450 invited_emails: Some(vec!["alex@example.com".into()]),
1451 party_masking: None,
1452 show_transcript: None,
1453 show_audio: None,
1454 allow_download: None,
1455 default_mute_local: None,
1456 default_mute_remote: None,
1457 password: None,
1458 expires_at: None,
1459 };
1460 let s = serde_json::to_string(&req).unwrap();
1461 assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1462 assert!(
1463 s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1464 "{s}"
1465 );
1466 }
1467
1468 #[test]
1469 fn share_response_parses_platform_shape() {
1470 let raw = r#"{
1471 "visibility": "public",
1472 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1473 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1474 "sharedAt": "2026-06-19T10:00:00.000Z"
1475 }"#;
1476 let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1477 assert_eq!(parsed.visibility, ShareVisibility::Public);
1478 assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1479 assert!(parsed.share_url.ends_with(&parsed.token));
1480 }
1481
1482 #[test]
1483 fn share_state_parses_restricted_with_invited_emails() {
1484 let raw = r#"{
1487 "visibility": "restricted",
1488 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1489 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1490 "sharedAt": "2026-06-19T10:00:00.000Z",
1491 "invitedEmails": ["bob@example.com", "carol@example.com"],
1492 "partyMasking": "full",
1493 "showTranscript": true,
1494 "showAudio": false,
1495 "allowDownload": false,
1496 "defaultMuteLocal": false,
1497 "defaultMuteRemote": true
1498 }"#;
1499 let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1500 assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1501 assert_eq!(
1502 parsed.invited_emails.as_deref(),
1503 Some(
1504 [
1505 "bob@example.com".to_string(),
1506 "carol@example.com".to_string()
1507 ]
1508 .as_slice()
1509 )
1510 );
1511 assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1513 assert_eq!(parsed.show_transcript, Some(true));
1514 assert_eq!(parsed.show_audio, Some(false));
1515 assert_eq!(parsed.allow_download, Some(false));
1517 assert_eq!(parsed.default_mute_local, Some(false));
1519 assert_eq!(parsed.default_mute_remote, Some(true));
1520 }
1521
1522 #[test]
1523 fn share_state_parses_private_with_fields_absent() {
1524 let parsed: ShareStateResponse =
1527 serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1528 assert_eq!(parsed.visibility, ShareVisibility::Private);
1529 assert!(parsed.token.is_none());
1530 assert!(parsed.share_url.is_none());
1531 assert!(parsed.shared_at.is_none());
1532 assert!(parsed.invited_emails.is_none());
1533 }
1534
1535 #[test]
1536 fn share_request_rejects_empty_source_id_before_hitting_network() {
1537 let req = ShareRecordingRequest {
1540 recording_source_id: String::new(),
1541 visibility: ShareVisibility::Private,
1542 invited_emails: None,
1543 party_masking: None,
1544 show_transcript: None,
1545 show_audio: None,
1546 allow_download: None,
1547 default_mute_local: None,
1548 default_mute_remote: None,
1549 password: None,
1550 expires_at: None,
1551 };
1552 assert!(req.recording_source_id.is_empty());
1556 }
1557
1558 fn sample_account() -> VoiceAccountRecord {
1561 VoiceAccountRecord {
1562 source_id: "11111111-1111-4111-8111-111111111111".into(),
1563 enabled: true,
1564 display_name: "Work line".into(),
1565 username: "alice".into(),
1566 domain: "sip.example.com".into(),
1567 auth_username: Some("alice-auth".into()),
1568 server: Some("sip.example.com".into()),
1569 port: Some(5060),
1570 transport: VoiceTransport::Udp,
1571 register_expires: 60,
1572 keepalive_secs: Some(50),
1573 disclosure_enabled: true,
1574 updated_at: "2026-06-20T10:00:00Z".into(),
1575 deleted_at: None,
1576 envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1577 }
1578 }
1579
1580 #[test]
1581 fn accounts_marker_resource_is_accounts() {
1582 assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1585 }
1586
1587 #[test]
1588 fn account_record_serializes_with_camel_case_and_envelope() {
1589 let s = serde_json::to_string(&sample_account()).unwrap();
1590 assert!(s.contains("\"sourceId\":"), "{s}");
1593 assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1594 assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1595 assert!(s.contains("\"registerExpires\":60"), "{s}");
1596 assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1597 assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1598 assert!(s.contains("\"transport\":\"udp\""), "{s}");
1599 assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1600 assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1602 assert!(!s.contains("password"), "no password field: {s}");
1604 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1606 }
1607
1608 #[test]
1609 fn account_tombstone_serializes_deleted_at() {
1610 let mut r = sample_account();
1613 r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1614 let s = serde_json::to_string(&r).unwrap();
1615 assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1616 }
1617
1618 #[test]
1619 fn account_record_round_trips_optional_fields() {
1620 let raw = r#"{
1623 "sourceId": "a",
1624 "enabled": false,
1625 "displayName": "Cheap trunk",
1626 "username": "u",
1627 "domain": "d",
1628 "transport": "tcp",
1629 "registerExpires": 120,
1630 "disclosureEnabled": false,
1631 "updatedAt": "2026-06-20T10:00:00Z"
1632 }"#;
1633 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1634 assert!(!parsed.enabled);
1635 assert!(parsed.auth_username.is_none());
1636 assert!(parsed.server.is_none());
1637 assert!(parsed.port.is_none());
1638 assert!(parsed.keepalive_secs.is_none());
1639 assert!(parsed.deleted_at.is_none());
1640 assert_eq!(parsed.transport, VoiceTransport::Tcp);
1641 assert_eq!(parsed.register_expires, 120);
1642 }
1643
1644 #[test]
1645 fn voice_transport_round_trips_via_json() {
1646 for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1647 let s = serde_json::to_string(&t).unwrap();
1648 let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1649 assert_eq!(t, back);
1650 }
1651 assert_eq!(
1654 serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1655 "\"udp\""
1656 );
1657 assert_eq!(
1658 serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1659 "\"tcp\""
1660 );
1661 }
1662
1663 #[test]
1664 fn accounts_query_omits_unset_and_serializes_include_deleted() {
1665 let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1666 assert_eq!(empty, "{}", "default query should be empty: {empty}");
1667 let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1668 include_deleted: Some(true),
1669 })
1670 .unwrap();
1671 assert!(
1672 with_deleted.contains("\"includeDeleted\":true"),
1673 "{with_deleted}"
1674 );
1675 }
1676
1677 #[test]
1678 fn account_record_accepts_unknown_extras_for_forward_compat() {
1679 let raw = r#"{
1682 "sourceId": "a",
1683 "enabled": true,
1684 "displayName": "x",
1685 "username": "u",
1686 "domain": "d",
1687 "transport": "udp",
1688 "registerExpires": 60,
1689 "disclosureEnabled": true,
1690 "updatedAt": "2026-06-20T10:00:00Z",
1691 "schemaVersion": 2,
1692 "extras": { "ringtone": "classic" }
1693 }"#;
1694 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1695 assert_eq!(parsed.envelope.schema_version, Some(2));
1696 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1697 assert_eq!(extras["ringtone"], "classic");
1698 }
1699}