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")]
686 pub default_mute_local: Option<bool>,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
688 pub default_mute_remote: Option<bool>,
689 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub password: Option<String>,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub expires_at: Option<String>,
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize)]
702#[serde(rename_all = "camelCase")]
703pub struct ShareRecordingResponse {
704 pub visibility: ShareVisibility,
705 pub token: String,
706 pub share_url: String,
707 pub shared_at: String,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub party_masking: Option<PartyMasking>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub show_transcript: Option<bool>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub show_audio: Option<bool>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
723 pub allow_download: Option<bool>,
724 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub default_mute_local: Option<bool>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub default_mute_remote: Option<bool>,
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize)]
743#[serde(rename_all = "camelCase")]
744pub struct ShareStateResponse {
745 pub visibility: ShareVisibility,
746 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub token: Option<String>,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub share_url: Option<String>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub shared_at: Option<String>,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub invited_emails: Option<Vec<String>>,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
761 pub party_masking: Option<PartyMasking>,
762 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub show_transcript: Option<bool>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub show_audio: Option<bool>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub allow_download: Option<bool>,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub default_mute_local: Option<bool>,
774 #[serde(default, skip_serializing_if = "Option::is_none")]
775 pub default_mute_remote: Option<bool>,
776}
777
778impl Client {
779 pub async fn share_recording(
787 &self,
788 req: &ShareRecordingRequest,
789 ) -> Result<ShareRecordingResponse> {
790 if req.recording_source_id.is_empty() {
791 return Err(Error::BadRequest(
792 "recording_source_id must not be empty".into(),
793 ));
794 }
795 let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
796 self.post_json::<ShareRecordingResponse, _>(&path, req)
797 .await
798 }
799
800 pub async fn get_recording_share(
806 &self,
807 recording_source_id: &str,
808 ) -> Result<ShareStateResponse> {
809 if recording_source_id.is_empty() {
810 return Err(Error::BadRequest(
811 "recording_source_id must not be empty".into(),
812 ));
813 }
814 let path = format!("/api/voice/recordings/{recording_source_id}/share");
815 self.get_json::<ShareStateResponse>(&path).await
816 }
817
818 pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
821 if recording_source_id.is_empty() {
822 return Err(Error::BadRequest(
823 "recording_source_id must not be empty".into(),
824 ));
825 }
826 let path = format!("/api/voice/recordings/{recording_source_id}/share");
827 self.delete(&path).await
828 }
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834
835 #[test]
836 fn share_visibility_types_are_reachable_from_the_crate_root() {
837 #[allow(dead_code)]
844 fn _reachable() {
845 let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
846 let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
847 let _: fn(&crate::ShareRecordingRequest) = |_| {};
848 let _: fn(&crate::ShareRecordingResponse) = |_| {};
849 }
850 }
851
852 #[test]
853 fn record_serializes_with_camel_case_keys() {
854 let r = VoiceCallRecord {
855 source_id: "11111111-1111-4111-8111-111111111111".into(),
856 account_id: "22222222-2222-4222-8222-222222222222".into(),
857 direction: VoiceCallDirection::Inbound,
858 party: "+14155550123".into(),
859 ring_at: "2026-05-16T10:00:00Z".into(),
860 answer_at: Some("2026-05-16T10:00:05Z".into()),
861 end_at: "2026-05-16T10:01:00Z".into(),
862 duration_ms: Some(55_000),
863 disposition: VoiceCallDisposition::Answered,
864 end_reason: VoiceCallEndReason::HangupRemote,
865 error: None,
866 share_visibility: None,
867 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
868 };
869 let s = serde_json::to_string(&r).unwrap();
870 assert!(s.contains("\"sourceId\":"), "{s}");
871 assert!(s.contains("\"accountId\":"), "{s}");
872 assert!(s.contains("\"ringAt\":"), "{s}");
873 assert!(s.contains("\"endAt\":"), "{s}");
874 assert!(s.contains("\"durationMs\":55000"), "{s}");
875 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
877 assert!(
881 s.contains("\"schemaVersion\":1"),
882 "schemaVersion should flatten: {s}"
883 );
884 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
887 }
888
889 #[test]
890 fn record_round_trips_optional_fields() {
891 let raw = r#"{
893 "sourceId": "a",
894 "accountId": "b",
895 "direction": "inbound",
896 "party": "anonymous",
897 "ringAt": "2026-05-16T10:00:00Z",
898 "endAt": "2026-05-16T10:00:30Z",
899 "disposition": "missed",
900 "endReason": "missed"
901 }"#;
902 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
903 assert!(parsed.answer_at.is_none());
904 assert!(parsed.duration_ms.is_none());
905 assert!(parsed.error.is_none());
906 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
907 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
908 }
909
910 #[test]
911 fn query_omits_unset_fields() {
912 let q = VoiceCallsQuery::default();
913 let s = serde_json::to_string(&q).unwrap();
914 assert_eq!(
916 s, "{}",
917 "default query should serialize to empty object: {s}"
918 );
919 }
920
921 #[test]
922 fn enum_round_trip_via_json() {
923 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
927 let s = serde_json::to_string(&d).unwrap();
928 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
929 assert_eq!(d, back);
930 }
931 for d in [
932 VoiceCallDisposition::Answered,
933 VoiceCallDisposition::Missed,
934 VoiceCallDisposition::Rejected,
935 VoiceCallDisposition::Cancelled,
936 VoiceCallDisposition::Failed,
937 ] {
938 let s = serde_json::to_string(&d).unwrap();
939 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
940 assert_eq!(d, back);
941 }
942 for r in [
943 VoiceCallEndReason::HangupLocal,
944 VoiceCallEndReason::HangupRemote,
945 VoiceCallEndReason::RejectedLocal,
946 VoiceCallEndReason::RejectedRemote,
947 VoiceCallEndReason::Missed,
948 VoiceCallEndReason::CancelledLocal,
949 VoiceCallEndReason::ConnectionLost,
950 VoiceCallEndReason::Failed,
951 ] {
952 let s = serde_json::to_string(&r).unwrap();
953 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
954 assert_eq!(r, back);
955 }
956 }
957
958 #[test]
959 fn connection_lost_pins_its_wire_string() {
960 let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
964 assert_eq!(s, "\"connection_lost\"");
965 }
966
967 #[test]
968 fn voice_calls_marker_resource_is_calls() {
969 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
970 }
971
972 #[test]
973 fn record_accepts_unknown_extras_for_forward_compat() {
974 let raw = r#"{
980 "sourceId": "a",
981 "accountId": "b",
982 "direction": "inbound",
983 "party": "anon",
984 "ringAt": "2026-05-16T10:00:00Z",
985 "endAt": "2026-05-16T10:00:30Z",
986 "disposition": "answered",
987 "endReason": "hangup_remote",
988 "schemaVersion": 2,
989 "extras": { "notes": "from staging build" }
990 }"#;
991 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
992 assert_eq!(parsed.envelope.schema_version, Some(2));
993 let extras = parsed.envelope.extras.as_ref().expect("extras present");
994 assert_eq!(extras["notes"], "from staging build");
995 }
996
997 #[test]
998 fn call_record_parses_share_visibility_from_list_response() {
999 let raw = r#"{
1002 "sourceId": "a",
1003 "accountId": "b",
1004 "direction": "outbound",
1005 "party": "+14155550123",
1006 "ringAt": "2026-05-16T10:00:00Z",
1007 "endAt": "2026-05-16T10:00:30Z",
1008 "disposition": "answered",
1009 "endReason": "hangup_remote",
1010 "shareVisibility": "public"
1011 }"#;
1012 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1013 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
1014
1015 let restricted = raw.replace("public", "restricted");
1016 let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
1017 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
1018 }
1019
1020 #[test]
1021 fn call_record_unshared_has_no_share_visibility() {
1022 let base = r#"{
1025 "sourceId": "a",
1026 "accountId": "b",
1027 "direction": "inbound",
1028 "party": "anon",
1029 "ringAt": "2026-05-16T10:00:00Z",
1030 "endAt": "2026-05-16T10:00:30Z",
1031 "disposition": "missed",
1032 "endReason": "missed"
1033 }"#;
1034 let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
1035 assert_eq!(parsed.share_visibility, None);
1036
1037 let with_null = base.replace(
1038 r#""endReason": "missed""#,
1039 r#""endReason": "missed", "shareVisibility": null"#,
1040 );
1041 let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1042 assert_eq!(parsed.share_visibility, None);
1043 }
1044
1045 #[test]
1046 fn synced_call_omits_share_visibility() {
1047 let raw = r#"{
1051 "sourceId": "a",
1052 "accountId": "b",
1053 "direction": "inbound",
1054 "party": "anon",
1055 "ringAt": "2026-05-16T10:00:00Z",
1056 "endAt": "2026-05-16T10:00:30Z",
1057 "disposition": "answered",
1058 "endReason": "hangup_remote"
1059 }"#;
1060 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1061 assert_eq!(parsed.share_visibility, None);
1062 let s = serde_json::to_string(&parsed).unwrap();
1063 assert!(
1064 !s.contains("shareVisibility"),
1065 "sync payload leaked share_visibility: {s}"
1066 );
1067 }
1068
1069 #[test]
1070 fn recording_marker_resource_is_recordings() {
1071 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1074 }
1075
1076 #[test]
1077 fn recording_record_serializes_with_camel_case_and_envelope() {
1078 let r = VoiceRecordingRecord {
1079 source_id: "11111111-1111-4111-8111-111111111111".into(),
1080 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1081 size_bytes: 44 + 64_000,
1082 duration_ms: 2_000,
1083 sample_rate: 8_000,
1084 channels: 2,
1085 created_at: "2026-05-16T10:01:05Z".into(),
1086 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1087 };
1088 let s = serde_json::to_string(&r).unwrap();
1089 assert!(s.contains("\"sourceId\":"), "{s}");
1092 assert!(s.contains("\"callSourceId\":"), "{s}");
1093 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1094 assert!(s.contains("\"durationMs\":2000"), "{s}");
1095 assert!(s.contains("\"sampleRate\":8000"), "{s}");
1096 assert!(s.contains("\"channels\":2"), "{s}");
1097 assert!(s.contains("\"createdAt\":"), "{s}");
1098 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1100 }
1101
1102 #[test]
1103 fn recordings_sync_response_round_trips() {
1104 let raw = r#"{
1109 "accepted": 2,
1110 "skipped": 0,
1111 "items": [
1112 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1113 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1114 ]
1115 }"#;
1116 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1117 assert_eq!(parsed.accepted, 2);
1118 assert_eq!(parsed.items.len(), 2);
1119 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1120 assert!(!parsed.items[0].bytes_uploaded);
1121 assert!(parsed.items[1].bytes_uploaded);
1122 }
1123
1124 #[test]
1125 fn install_heartbeat_request_serializes_with_camel_case_keys() {
1126 let req = InstallHeartbeatRequest {
1127 install_id: "11111111-1111-4111-8111-111111111111".into(),
1128 app_version: "0.0.21".into(),
1129 os: "macos".into(),
1130 os_version: Some("15.5.0".into()),
1131 arch: Some("aarch64".into()),
1132 locale: Some("en-NZ".into()),
1133 };
1134 let s = serde_json::to_string(&req).unwrap();
1135 assert!(s.contains("\"installId\":"), "{s}");
1136 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1137 assert!(s.contains("\"os\":\"macos\""), "{s}");
1138 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1139 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1140 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1141 }
1142
1143 #[test]
1144 fn install_heartbeat_request_omits_absent_optional_fields() {
1145 let req = InstallHeartbeatRequest {
1150 install_id: "x".into(),
1151 app_version: "0.0.21".into(),
1152 os: "linux".into(),
1153 os_version: None,
1154 arch: None,
1155 locale: None,
1156 };
1157 let s = serde_json::to_string(&req).unwrap();
1158 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1159 assert!(!s.contains("arch"), "arch should be omitted: {s}");
1160 assert!(!s.contains("locale"), "locale should be omitted: {s}");
1161 }
1162
1163 #[test]
1164 fn install_heartbeat_response_parses_platform_shape() {
1165 let raw = r#"{
1166 "id": "abc-123",
1167 "installId": "11111111-1111-4111-8111-111111111111",
1168 "appVersion": "0.0.21",
1169 "os": "macos",
1170 "osVersion": "15.5.0",
1171 "arch": "aarch64",
1172 "locale": null,
1173 "firstSeenAt": "2026-05-31T10:00:00.000Z",
1174 "lastSeenAt": "2026-05-31T10:00:00.000Z"
1175 }"#;
1176 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1177 assert_eq!(parsed.id, "abc-123");
1178 assert_eq!(parsed.app_version, "0.0.21");
1179 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1180 assert!(parsed.locale.is_none());
1181 }
1182
1183 #[test]
1184 fn system_info_detect_fills_os_and_arch() {
1185 let sys = SystemInfo::detect();
1189 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1190 assert!(
1191 !sys.arch.is_empty(),
1192 "arch should be a non-empty target string"
1193 );
1194 }
1195
1196 #[test]
1197 fn transcripts_marker_resource_is_transcripts() {
1198 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1199 }
1200
1201 #[test]
1202 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1203 let r = VoiceTranscriptRecord {
1204 source_id: "1".into(),
1205 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1206 channel: VoiceTranscriptChannel::Remote,
1207 ts_ms: 100,
1208 end_ms: 1_500,
1209 text: "hello".into(),
1210 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1211 };
1212 let s = serde_json::to_string(&r).unwrap();
1213 assert!(s.contains("\"sourceId\":"), "{s}");
1214 assert!(s.contains("\"callSourceId\":"), "{s}");
1215 assert!(s.contains("\"channel\":\"remote\""), "{s}");
1218 assert!(s.contains("\"tsMs\":100"), "{s}");
1219 assert!(s.contains("\"endMs\":1500"), "{s}");
1220 assert!(s.contains("\"text\":\"hello\""), "{s}");
1221 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1222 }
1223
1224 #[test]
1225 fn share_visibility_pins_its_wire_strings() {
1226 assert_eq!(
1229 serde_json::to_string(&ShareVisibility::Private).unwrap(),
1230 "\"private\""
1231 );
1232 assert_eq!(
1233 serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1234 "\"restricted\""
1235 );
1236 assert_eq!(
1237 serde_json::to_string(&ShareVisibility::Public).unwrap(),
1238 "\"public\""
1239 );
1240 for v in [
1241 ShareVisibility::Private,
1242 ShareVisibility::Restricted,
1243 ShareVisibility::Public,
1244 ] {
1245 let s = serde_json::to_string(&v).unwrap();
1246 let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1247 assert_eq!(v, back);
1248 }
1249 }
1250
1251 #[test]
1252 fn share_request_serializes_with_camel_case_and_omits_unset() {
1253 let req = ShareRecordingRequest {
1254 recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1255 visibility: ShareVisibility::Public,
1256 invited_emails: None,
1257 party_masking: None,
1258 show_transcript: None,
1259 show_audio: None,
1260 allow_download: None,
1261 default_mute_local: None,
1262 default_mute_remote: None,
1263 password: None,
1264 expires_at: None,
1265 };
1266 let s = serde_json::to_string(&req).unwrap();
1267 assert!(s.contains("\"recordingSourceId\":"), "{s}");
1268 assert!(s.contains("\"visibility\":\"public\""), "{s}");
1269 assert!(!s.contains("invitedEmails"), "{s}");
1273 assert!(!s.contains("partyMasking"), "{s}");
1274 assert!(!s.contains("showTranscript"), "{s}");
1275 assert!(!s.contains("showAudio"), "{s}");
1276 assert!(!s.contains("allowDownload"), "{s}");
1277 assert!(!s.contains("defaultMuteLocal"), "{s}");
1278 assert!(!s.contains("defaultMuteRemote"), "{s}");
1279 assert!(!s.contains("password"), "{s}");
1280 assert!(!s.contains("expiresAt"), "{s}");
1281 }
1282
1283 #[test]
1284 fn share_request_serializes_visibility_controls_camel_case() {
1285 let req = ShareRecordingRequest {
1286 recording_source_id: "a".into(),
1287 visibility: ShareVisibility::Public,
1288 invited_emails: None,
1289 party_masking: Some(PartyMasking::Partial),
1290 show_transcript: Some(false),
1291 show_audio: Some(true),
1292 allow_download: Some(true),
1293 default_mute_local: Some(false),
1294 default_mute_remote: Some(true),
1295 password: None,
1296 expires_at: None,
1297 };
1298 let s = serde_json::to_string(&req).unwrap();
1299 assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1300 assert!(s.contains("\"showTranscript\":false"), "{s}");
1301 assert!(s.contains("\"showAudio\":true"), "{s}");
1302 assert!(s.contains("\"allowDownload\":true"), "{s}");
1303 assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
1306 assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
1307 }
1308
1309 #[test]
1310 fn share_request_carries_invited_emails_for_restricted() {
1311 let req = ShareRecordingRequest {
1312 recording_source_id: "a".into(),
1313 visibility: ShareVisibility::Restricted,
1314 invited_emails: Some(vec!["alex@example.com".into()]),
1315 party_masking: None,
1316 show_transcript: None,
1317 show_audio: None,
1318 allow_download: None,
1319 default_mute_local: None,
1320 default_mute_remote: None,
1321 password: None,
1322 expires_at: None,
1323 };
1324 let s = serde_json::to_string(&req).unwrap();
1325 assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1326 assert!(
1327 s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1328 "{s}"
1329 );
1330 }
1331
1332 #[test]
1333 fn share_response_parses_platform_shape() {
1334 let raw = r#"{
1335 "visibility": "public",
1336 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1337 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1338 "sharedAt": "2026-06-19T10:00:00.000Z"
1339 }"#;
1340 let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1341 assert_eq!(parsed.visibility, ShareVisibility::Public);
1342 assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1343 assert!(parsed.share_url.ends_with(&parsed.token));
1344 }
1345
1346 #[test]
1347 fn share_state_parses_restricted_with_invited_emails() {
1348 let raw = r#"{
1351 "visibility": "restricted",
1352 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1353 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1354 "sharedAt": "2026-06-19T10:00:00.000Z",
1355 "invitedEmails": ["bob@example.com", "carol@example.com"],
1356 "partyMasking": "full",
1357 "showTranscript": true,
1358 "showAudio": false,
1359 "allowDownload": false,
1360 "defaultMuteLocal": false,
1361 "defaultMuteRemote": true
1362 }"#;
1363 let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1364 assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1365 assert_eq!(
1366 parsed.invited_emails.as_deref(),
1367 Some(
1368 [
1369 "bob@example.com".to_string(),
1370 "carol@example.com".to_string()
1371 ]
1372 .as_slice()
1373 )
1374 );
1375 assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1377 assert_eq!(parsed.show_transcript, Some(true));
1378 assert_eq!(parsed.show_audio, Some(false));
1379 assert_eq!(parsed.allow_download, Some(false));
1381 assert_eq!(parsed.default_mute_local, Some(false));
1383 assert_eq!(parsed.default_mute_remote, Some(true));
1384 }
1385
1386 #[test]
1387 fn share_state_parses_private_with_fields_absent() {
1388 let parsed: ShareStateResponse =
1391 serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1392 assert_eq!(parsed.visibility, ShareVisibility::Private);
1393 assert!(parsed.token.is_none());
1394 assert!(parsed.share_url.is_none());
1395 assert!(parsed.shared_at.is_none());
1396 assert!(parsed.invited_emails.is_none());
1397 }
1398
1399 #[test]
1400 fn share_request_rejects_empty_source_id_before_hitting_network() {
1401 let req = ShareRecordingRequest {
1404 recording_source_id: String::new(),
1405 visibility: ShareVisibility::Private,
1406 invited_emails: None,
1407 party_masking: None,
1408 show_transcript: None,
1409 show_audio: None,
1410 allow_download: None,
1411 default_mute_local: None,
1412 default_mute_remote: None,
1413 password: None,
1414 expires_at: None,
1415 };
1416 assert!(req.recording_source_id.is_empty());
1420 }
1421
1422 fn sample_account() -> VoiceAccountRecord {
1425 VoiceAccountRecord {
1426 source_id: "11111111-1111-4111-8111-111111111111".into(),
1427 enabled: true,
1428 display_name: "Work line".into(),
1429 username: "alice".into(),
1430 domain: "sip.example.com".into(),
1431 auth_username: Some("alice-auth".into()),
1432 server: Some("sip.example.com".into()),
1433 port: Some(5060),
1434 transport: VoiceTransport::Udp,
1435 register_expires: 60,
1436 keepalive_secs: Some(50),
1437 disclosure_enabled: true,
1438 updated_at: "2026-06-20T10:00:00Z".into(),
1439 deleted_at: None,
1440 envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1441 }
1442 }
1443
1444 #[test]
1445 fn accounts_marker_resource_is_accounts() {
1446 assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1449 }
1450
1451 #[test]
1452 fn account_record_serializes_with_camel_case_and_envelope() {
1453 let s = serde_json::to_string(&sample_account()).unwrap();
1454 assert!(s.contains("\"sourceId\":"), "{s}");
1457 assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1458 assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1459 assert!(s.contains("\"registerExpires\":60"), "{s}");
1460 assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1461 assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1462 assert!(s.contains("\"transport\":\"udp\""), "{s}");
1463 assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1464 assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1466 assert!(!s.contains("password"), "no password field: {s}");
1468 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1470 }
1471
1472 #[test]
1473 fn account_tombstone_serializes_deleted_at() {
1474 let mut r = sample_account();
1477 r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1478 let s = serde_json::to_string(&r).unwrap();
1479 assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1480 }
1481
1482 #[test]
1483 fn account_record_round_trips_optional_fields() {
1484 let raw = r#"{
1487 "sourceId": "a",
1488 "enabled": false,
1489 "displayName": "Cheap trunk",
1490 "username": "u",
1491 "domain": "d",
1492 "transport": "tcp",
1493 "registerExpires": 120,
1494 "disclosureEnabled": false,
1495 "updatedAt": "2026-06-20T10:00:00Z"
1496 }"#;
1497 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1498 assert!(!parsed.enabled);
1499 assert!(parsed.auth_username.is_none());
1500 assert!(parsed.server.is_none());
1501 assert!(parsed.port.is_none());
1502 assert!(parsed.keepalive_secs.is_none());
1503 assert!(parsed.deleted_at.is_none());
1504 assert_eq!(parsed.transport, VoiceTransport::Tcp);
1505 assert_eq!(parsed.register_expires, 120);
1506 }
1507
1508 #[test]
1509 fn voice_transport_round_trips_via_json() {
1510 for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1511 let s = serde_json::to_string(&t).unwrap();
1512 let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1513 assert_eq!(t, back);
1514 }
1515 assert_eq!(
1518 serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1519 "\"udp\""
1520 );
1521 assert_eq!(
1522 serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1523 "\"tcp\""
1524 );
1525 }
1526
1527 #[test]
1528 fn accounts_query_omits_unset_and_serializes_include_deleted() {
1529 let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1530 assert_eq!(empty, "{}", "default query should be empty: {empty}");
1531 let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1532 include_deleted: Some(true),
1533 })
1534 .unwrap();
1535 assert!(
1536 with_deleted.contains("\"includeDeleted\":true"),
1537 "{with_deleted}"
1538 );
1539 }
1540
1541 #[test]
1542 fn account_record_accepts_unknown_extras_for_forward_compat() {
1543 let raw = r#"{
1546 "sourceId": "a",
1547 "enabled": true,
1548 "displayName": "x",
1549 "username": "u",
1550 "domain": "d",
1551 "transport": "udp",
1552 "registerExpires": 60,
1553 "disclosureEnabled": true,
1554 "updatedAt": "2026-06-20T10:00:00Z",
1555 "schemaVersion": 2,
1556 "extras": { "ringtone": "classic" }
1557 }"#;
1558 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1559 assert_eq!(parsed.envelope.schema_version, Some(2));
1560 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1561 assert_eq!(extras["ringtone"], "classic");
1562 }
1563}