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")]
672 pub password: Option<String>,
673 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub expires_at: Option<String>,
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize)]
683#[serde(rename_all = "camelCase")]
684pub struct ShareRecordingResponse {
685 pub visibility: ShareVisibility,
686 pub token: String,
687 pub share_url: String,
688 pub shared_at: String,
690 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub party_masking: Option<PartyMasking>,
696 #[serde(default, skip_serializing_if = "Option::is_none")]
697 pub show_transcript: Option<bool>,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub show_audio: Option<bool>,
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize)]
712#[serde(rename_all = "camelCase")]
713pub struct ShareStateResponse {
714 pub visibility: ShareVisibility,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub token: Option<String>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub share_url: Option<String>,
720 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub shared_at: Option<String>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub invited_emails: Option<Vec<String>>,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub party_masking: Option<PartyMasking>,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub show_transcript: Option<bool>,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub show_audio: Option<bool>,
735}
736
737impl Client {
738 pub async fn share_recording(
746 &self,
747 req: &ShareRecordingRequest,
748 ) -> Result<ShareRecordingResponse> {
749 if req.recording_source_id.is_empty() {
750 return Err(Error::BadRequest(
751 "recording_source_id must not be empty".into(),
752 ));
753 }
754 let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
755 self.post_json::<ShareRecordingResponse, _>(&path, req)
756 .await
757 }
758
759 pub async fn get_recording_share(
765 &self,
766 recording_source_id: &str,
767 ) -> Result<ShareStateResponse> {
768 if recording_source_id.is_empty() {
769 return Err(Error::BadRequest(
770 "recording_source_id must not be empty".into(),
771 ));
772 }
773 let path = format!("/api/voice/recordings/{recording_source_id}/share");
774 self.get_json::<ShareStateResponse>(&path).await
775 }
776
777 pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
780 if recording_source_id.is_empty() {
781 return Err(Error::BadRequest(
782 "recording_source_id must not be empty".into(),
783 ));
784 }
785 let path = format!("/api/voice/recordings/{recording_source_id}/share");
786 self.delete(&path).await
787 }
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn record_serializes_with_camel_case_keys() {
796 let r = VoiceCallRecord {
797 source_id: "11111111-1111-4111-8111-111111111111".into(),
798 account_id: "22222222-2222-4222-8222-222222222222".into(),
799 direction: VoiceCallDirection::Inbound,
800 party: "+14155550123".into(),
801 ring_at: "2026-05-16T10:00:00Z".into(),
802 answer_at: Some("2026-05-16T10:00:05Z".into()),
803 end_at: "2026-05-16T10:01:00Z".into(),
804 duration_ms: Some(55_000),
805 disposition: VoiceCallDisposition::Answered,
806 end_reason: VoiceCallEndReason::HangupRemote,
807 error: None,
808 share_visibility: None,
809 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
810 };
811 let s = serde_json::to_string(&r).unwrap();
812 assert!(s.contains("\"sourceId\":"), "{s}");
813 assert!(s.contains("\"accountId\":"), "{s}");
814 assert!(s.contains("\"ringAt\":"), "{s}");
815 assert!(s.contains("\"endAt\":"), "{s}");
816 assert!(s.contains("\"durationMs\":55000"), "{s}");
817 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
819 assert!(
823 s.contains("\"schemaVersion\":1"),
824 "schemaVersion should flatten: {s}"
825 );
826 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
829 }
830
831 #[test]
832 fn record_round_trips_optional_fields() {
833 let raw = r#"{
835 "sourceId": "a",
836 "accountId": "b",
837 "direction": "inbound",
838 "party": "anonymous",
839 "ringAt": "2026-05-16T10:00:00Z",
840 "endAt": "2026-05-16T10:00:30Z",
841 "disposition": "missed",
842 "endReason": "missed"
843 }"#;
844 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
845 assert!(parsed.answer_at.is_none());
846 assert!(parsed.duration_ms.is_none());
847 assert!(parsed.error.is_none());
848 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
849 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
850 }
851
852 #[test]
853 fn query_omits_unset_fields() {
854 let q = VoiceCallsQuery::default();
855 let s = serde_json::to_string(&q).unwrap();
856 assert_eq!(
858 s, "{}",
859 "default query should serialize to empty object: {s}"
860 );
861 }
862
863 #[test]
864 fn enum_round_trip_via_json() {
865 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
869 let s = serde_json::to_string(&d).unwrap();
870 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
871 assert_eq!(d, back);
872 }
873 for d in [
874 VoiceCallDisposition::Answered,
875 VoiceCallDisposition::Missed,
876 VoiceCallDisposition::Rejected,
877 VoiceCallDisposition::Cancelled,
878 VoiceCallDisposition::Failed,
879 ] {
880 let s = serde_json::to_string(&d).unwrap();
881 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
882 assert_eq!(d, back);
883 }
884 for r in [
885 VoiceCallEndReason::HangupLocal,
886 VoiceCallEndReason::HangupRemote,
887 VoiceCallEndReason::RejectedLocal,
888 VoiceCallEndReason::RejectedRemote,
889 VoiceCallEndReason::Missed,
890 VoiceCallEndReason::CancelledLocal,
891 VoiceCallEndReason::ConnectionLost,
892 VoiceCallEndReason::Failed,
893 ] {
894 let s = serde_json::to_string(&r).unwrap();
895 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
896 assert_eq!(r, back);
897 }
898 }
899
900 #[test]
901 fn connection_lost_pins_its_wire_string() {
902 let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
906 assert_eq!(s, "\"connection_lost\"");
907 }
908
909 #[test]
910 fn voice_calls_marker_resource_is_calls() {
911 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
912 }
913
914 #[test]
915 fn record_accepts_unknown_extras_for_forward_compat() {
916 let raw = r#"{
922 "sourceId": "a",
923 "accountId": "b",
924 "direction": "inbound",
925 "party": "anon",
926 "ringAt": "2026-05-16T10:00:00Z",
927 "endAt": "2026-05-16T10:00:30Z",
928 "disposition": "answered",
929 "endReason": "hangup_remote",
930 "schemaVersion": 2,
931 "extras": { "notes": "from staging build" }
932 }"#;
933 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
934 assert_eq!(parsed.envelope.schema_version, Some(2));
935 let extras = parsed.envelope.extras.as_ref().expect("extras present");
936 assert_eq!(extras["notes"], "from staging build");
937 }
938
939 #[test]
940 fn call_record_parses_share_visibility_from_list_response() {
941 let raw = r#"{
944 "sourceId": "a",
945 "accountId": "b",
946 "direction": "outbound",
947 "party": "+14155550123",
948 "ringAt": "2026-05-16T10:00:00Z",
949 "endAt": "2026-05-16T10:00:30Z",
950 "disposition": "answered",
951 "endReason": "hangup_remote",
952 "shareVisibility": "public"
953 }"#;
954 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
955 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
956
957 let restricted = raw.replace("public", "restricted");
958 let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
959 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
960 }
961
962 #[test]
963 fn call_record_unshared_has_no_share_visibility() {
964 let base = r#"{
967 "sourceId": "a",
968 "accountId": "b",
969 "direction": "inbound",
970 "party": "anon",
971 "ringAt": "2026-05-16T10:00:00Z",
972 "endAt": "2026-05-16T10:00:30Z",
973 "disposition": "missed",
974 "endReason": "missed"
975 }"#;
976 let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
977 assert_eq!(parsed.share_visibility, None);
978
979 let with_null = base.replace(
980 r#""endReason": "missed""#,
981 r#""endReason": "missed", "shareVisibility": null"#,
982 );
983 let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
984 assert_eq!(parsed.share_visibility, None);
985 }
986
987 #[test]
988 fn synced_call_omits_share_visibility() {
989 let raw = r#"{
993 "sourceId": "a",
994 "accountId": "b",
995 "direction": "inbound",
996 "party": "anon",
997 "ringAt": "2026-05-16T10:00:00Z",
998 "endAt": "2026-05-16T10:00:30Z",
999 "disposition": "answered",
1000 "endReason": "hangup_remote"
1001 }"#;
1002 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1003 assert_eq!(parsed.share_visibility, None);
1004 let s = serde_json::to_string(&parsed).unwrap();
1005 assert!(
1006 !s.contains("shareVisibility"),
1007 "sync payload leaked share_visibility: {s}"
1008 );
1009 }
1010
1011 #[test]
1012 fn recording_marker_resource_is_recordings() {
1013 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1016 }
1017
1018 #[test]
1019 fn recording_record_serializes_with_camel_case_and_envelope() {
1020 let r = VoiceRecordingRecord {
1021 source_id: "11111111-1111-4111-8111-111111111111".into(),
1022 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1023 size_bytes: 44 + 64_000,
1024 duration_ms: 2_000,
1025 sample_rate: 8_000,
1026 channels: 2,
1027 created_at: "2026-05-16T10:01:05Z".into(),
1028 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1029 };
1030 let s = serde_json::to_string(&r).unwrap();
1031 assert!(s.contains("\"sourceId\":"), "{s}");
1034 assert!(s.contains("\"callSourceId\":"), "{s}");
1035 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1036 assert!(s.contains("\"durationMs\":2000"), "{s}");
1037 assert!(s.contains("\"sampleRate\":8000"), "{s}");
1038 assert!(s.contains("\"channels\":2"), "{s}");
1039 assert!(s.contains("\"createdAt\":"), "{s}");
1040 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1042 }
1043
1044 #[test]
1045 fn recordings_sync_response_round_trips() {
1046 let raw = r#"{
1051 "accepted": 2,
1052 "skipped": 0,
1053 "items": [
1054 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1055 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1056 ]
1057 }"#;
1058 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1059 assert_eq!(parsed.accepted, 2);
1060 assert_eq!(parsed.items.len(), 2);
1061 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1062 assert!(!parsed.items[0].bytes_uploaded);
1063 assert!(parsed.items[1].bytes_uploaded);
1064 }
1065
1066 #[test]
1067 fn install_heartbeat_request_serializes_with_camel_case_keys() {
1068 let req = InstallHeartbeatRequest {
1069 install_id: "11111111-1111-4111-8111-111111111111".into(),
1070 app_version: "0.0.21".into(),
1071 os: "macos".into(),
1072 os_version: Some("15.5.0".into()),
1073 arch: Some("aarch64".into()),
1074 locale: Some("en-NZ".into()),
1075 };
1076 let s = serde_json::to_string(&req).unwrap();
1077 assert!(s.contains("\"installId\":"), "{s}");
1078 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1079 assert!(s.contains("\"os\":\"macos\""), "{s}");
1080 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1081 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1082 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1083 }
1084
1085 #[test]
1086 fn install_heartbeat_request_omits_absent_optional_fields() {
1087 let req = InstallHeartbeatRequest {
1092 install_id: "x".into(),
1093 app_version: "0.0.21".into(),
1094 os: "linux".into(),
1095 os_version: None,
1096 arch: None,
1097 locale: None,
1098 };
1099 let s = serde_json::to_string(&req).unwrap();
1100 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1101 assert!(!s.contains("arch"), "arch should be omitted: {s}");
1102 assert!(!s.contains("locale"), "locale should be omitted: {s}");
1103 }
1104
1105 #[test]
1106 fn install_heartbeat_response_parses_platform_shape() {
1107 let raw = r#"{
1108 "id": "abc-123",
1109 "installId": "11111111-1111-4111-8111-111111111111",
1110 "appVersion": "0.0.21",
1111 "os": "macos",
1112 "osVersion": "15.5.0",
1113 "arch": "aarch64",
1114 "locale": null,
1115 "firstSeenAt": "2026-05-31T10:00:00.000Z",
1116 "lastSeenAt": "2026-05-31T10:00:00.000Z"
1117 }"#;
1118 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1119 assert_eq!(parsed.id, "abc-123");
1120 assert_eq!(parsed.app_version, "0.0.21");
1121 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1122 assert!(parsed.locale.is_none());
1123 }
1124
1125 #[test]
1126 fn system_info_detect_fills_os_and_arch() {
1127 let sys = SystemInfo::detect();
1131 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1132 assert!(
1133 !sys.arch.is_empty(),
1134 "arch should be a non-empty target string"
1135 );
1136 }
1137
1138 #[test]
1139 fn transcripts_marker_resource_is_transcripts() {
1140 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1141 }
1142
1143 #[test]
1144 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1145 let r = VoiceTranscriptRecord {
1146 source_id: "1".into(),
1147 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1148 channel: VoiceTranscriptChannel::Remote,
1149 ts_ms: 100,
1150 end_ms: 1_500,
1151 text: "hello".into(),
1152 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1153 };
1154 let s = serde_json::to_string(&r).unwrap();
1155 assert!(s.contains("\"sourceId\":"), "{s}");
1156 assert!(s.contains("\"callSourceId\":"), "{s}");
1157 assert!(s.contains("\"channel\":\"remote\""), "{s}");
1160 assert!(s.contains("\"tsMs\":100"), "{s}");
1161 assert!(s.contains("\"endMs\":1500"), "{s}");
1162 assert!(s.contains("\"text\":\"hello\""), "{s}");
1163 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1164 }
1165
1166 #[test]
1167 fn share_visibility_pins_its_wire_strings() {
1168 assert_eq!(
1171 serde_json::to_string(&ShareVisibility::Private).unwrap(),
1172 "\"private\""
1173 );
1174 assert_eq!(
1175 serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1176 "\"restricted\""
1177 );
1178 assert_eq!(
1179 serde_json::to_string(&ShareVisibility::Public).unwrap(),
1180 "\"public\""
1181 );
1182 for v in [
1183 ShareVisibility::Private,
1184 ShareVisibility::Restricted,
1185 ShareVisibility::Public,
1186 ] {
1187 let s = serde_json::to_string(&v).unwrap();
1188 let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1189 assert_eq!(v, back);
1190 }
1191 }
1192
1193 #[test]
1194 fn share_request_serializes_with_camel_case_and_omits_unset() {
1195 let req = ShareRecordingRequest {
1196 recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1197 visibility: ShareVisibility::Public,
1198 invited_emails: None,
1199 party_masking: None,
1200 show_transcript: None,
1201 show_audio: None,
1202 password: None,
1203 expires_at: None,
1204 };
1205 let s = serde_json::to_string(&req).unwrap();
1206 assert!(s.contains("\"recordingSourceId\":"), "{s}");
1207 assert!(s.contains("\"visibility\":\"public\""), "{s}");
1208 assert!(!s.contains("invitedEmails"), "{s}");
1212 assert!(!s.contains("partyMasking"), "{s}");
1213 assert!(!s.contains("showTranscript"), "{s}");
1214 assert!(!s.contains("showAudio"), "{s}");
1215 assert!(!s.contains("password"), "{s}");
1216 assert!(!s.contains("expiresAt"), "{s}");
1217 }
1218
1219 #[test]
1220 fn share_request_serializes_visibility_controls_camel_case() {
1221 let req = ShareRecordingRequest {
1222 recording_source_id: "a".into(),
1223 visibility: ShareVisibility::Public,
1224 invited_emails: None,
1225 party_masking: Some(PartyMasking::Partial),
1226 show_transcript: Some(false),
1227 show_audio: Some(true),
1228 password: None,
1229 expires_at: None,
1230 };
1231 let s = serde_json::to_string(&req).unwrap();
1232 assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1233 assert!(s.contains("\"showTranscript\":false"), "{s}");
1234 assert!(s.contains("\"showAudio\":true"), "{s}");
1235 }
1236
1237 #[test]
1238 fn share_request_carries_invited_emails_for_restricted() {
1239 let req = ShareRecordingRequest {
1240 recording_source_id: "a".into(),
1241 visibility: ShareVisibility::Restricted,
1242 invited_emails: Some(vec!["alex@example.com".into()]),
1243 party_masking: None,
1244 show_transcript: None,
1245 show_audio: None,
1246 password: None,
1247 expires_at: None,
1248 };
1249 let s = serde_json::to_string(&req).unwrap();
1250 assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1251 assert!(
1252 s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1253 "{s}"
1254 );
1255 }
1256
1257 #[test]
1258 fn share_response_parses_platform_shape() {
1259 let raw = r#"{
1260 "visibility": "public",
1261 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1262 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1263 "sharedAt": "2026-06-19T10:00:00.000Z"
1264 }"#;
1265 let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1266 assert_eq!(parsed.visibility, ShareVisibility::Public);
1267 assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1268 assert!(parsed.share_url.ends_with(&parsed.token));
1269 }
1270
1271 #[test]
1272 fn share_state_parses_restricted_with_invited_emails() {
1273 let raw = r#"{
1276 "visibility": "restricted",
1277 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1278 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1279 "sharedAt": "2026-06-19T10:00:00.000Z",
1280 "invitedEmails": ["bob@example.com", "carol@example.com"],
1281 "partyMasking": "full",
1282 "showTranscript": true,
1283 "showAudio": false
1284 }"#;
1285 let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1286 assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1287 assert_eq!(
1288 parsed.invited_emails.as_deref(),
1289 Some(
1290 [
1291 "bob@example.com".to_string(),
1292 "carol@example.com".to_string()
1293 ]
1294 .as_slice()
1295 )
1296 );
1297 assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1299 assert_eq!(parsed.show_transcript, Some(true));
1300 assert_eq!(parsed.show_audio, Some(false));
1301 }
1302
1303 #[test]
1304 fn share_state_parses_private_with_fields_absent() {
1305 let parsed: ShareStateResponse =
1308 serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1309 assert_eq!(parsed.visibility, ShareVisibility::Private);
1310 assert!(parsed.token.is_none());
1311 assert!(parsed.share_url.is_none());
1312 assert!(parsed.shared_at.is_none());
1313 assert!(parsed.invited_emails.is_none());
1314 }
1315
1316 #[test]
1317 fn share_request_rejects_empty_source_id_before_hitting_network() {
1318 let req = ShareRecordingRequest {
1321 recording_source_id: String::new(),
1322 visibility: ShareVisibility::Private,
1323 invited_emails: None,
1324 party_masking: None,
1325 show_transcript: None,
1326 show_audio: None,
1327 password: None,
1328 expires_at: None,
1329 };
1330 assert!(req.recording_source_id.is_empty());
1334 }
1335
1336 fn sample_account() -> VoiceAccountRecord {
1339 VoiceAccountRecord {
1340 source_id: "11111111-1111-4111-8111-111111111111".into(),
1341 enabled: true,
1342 display_name: "Work line".into(),
1343 username: "alice".into(),
1344 domain: "sip.example.com".into(),
1345 auth_username: Some("alice-auth".into()),
1346 server: Some("sip.example.com".into()),
1347 port: Some(5060),
1348 transport: VoiceTransport::Udp,
1349 register_expires: 60,
1350 keepalive_secs: Some(50),
1351 disclosure_enabled: true,
1352 updated_at: "2026-06-20T10:00:00Z".into(),
1353 deleted_at: None,
1354 envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1355 }
1356 }
1357
1358 #[test]
1359 fn accounts_marker_resource_is_accounts() {
1360 assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1363 }
1364
1365 #[test]
1366 fn account_record_serializes_with_camel_case_and_envelope() {
1367 let s = serde_json::to_string(&sample_account()).unwrap();
1368 assert!(s.contains("\"sourceId\":"), "{s}");
1371 assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1372 assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1373 assert!(s.contains("\"registerExpires\":60"), "{s}");
1374 assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1375 assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1376 assert!(s.contains("\"transport\":\"udp\""), "{s}");
1377 assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1378 assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1380 assert!(!s.contains("password"), "no password field: {s}");
1382 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1384 }
1385
1386 #[test]
1387 fn account_tombstone_serializes_deleted_at() {
1388 let mut r = sample_account();
1391 r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1392 let s = serde_json::to_string(&r).unwrap();
1393 assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1394 }
1395
1396 #[test]
1397 fn account_record_round_trips_optional_fields() {
1398 let raw = r#"{
1401 "sourceId": "a",
1402 "enabled": false,
1403 "displayName": "Cheap trunk",
1404 "username": "u",
1405 "domain": "d",
1406 "transport": "tcp",
1407 "registerExpires": 120,
1408 "disclosureEnabled": false,
1409 "updatedAt": "2026-06-20T10:00:00Z"
1410 }"#;
1411 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1412 assert!(!parsed.enabled);
1413 assert!(parsed.auth_username.is_none());
1414 assert!(parsed.server.is_none());
1415 assert!(parsed.port.is_none());
1416 assert!(parsed.keepalive_secs.is_none());
1417 assert!(parsed.deleted_at.is_none());
1418 assert_eq!(parsed.transport, VoiceTransport::Tcp);
1419 assert_eq!(parsed.register_expires, 120);
1420 }
1421
1422 #[test]
1423 fn voice_transport_round_trips_via_json() {
1424 for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1425 let s = serde_json::to_string(&t).unwrap();
1426 let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1427 assert_eq!(t, back);
1428 }
1429 assert_eq!(
1432 serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1433 "\"udp\""
1434 );
1435 assert_eq!(
1436 serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1437 "\"tcp\""
1438 );
1439 }
1440
1441 #[test]
1442 fn accounts_query_omits_unset_and_serializes_include_deleted() {
1443 let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1444 assert_eq!(empty, "{}", "default query should be empty: {empty}");
1445 let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1446 include_deleted: Some(true),
1447 })
1448 .unwrap();
1449 assert!(
1450 with_deleted.contains("\"includeDeleted\":true"),
1451 "{with_deleted}"
1452 );
1453 }
1454
1455 #[test]
1456 fn account_record_accepts_unknown_extras_for_forward_compat() {
1457 let raw = r#"{
1460 "sourceId": "a",
1461 "enabled": true,
1462 "displayName": "x",
1463 "username": "u",
1464 "domain": "d",
1465 "transport": "udp",
1466 "registerExpires": 60,
1467 "disclosureEnabled": true,
1468 "updatedAt": "2026-06-20T10:00:00Z",
1469 "schemaVersion": 2,
1470 "extras": { "ringtone": "classic" }
1471 }"#;
1472 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1473 assert_eq!(parsed.envelope.schema_version, Some(2));
1474 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1475 assert_eq!(extras["ringtone"], "classic");
1476 }
1477}