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 share_visibility_types_are_reachable_from_the_crate_root() {
796 #[allow(dead_code)]
803 fn _reachable() {
804 let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
805 let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
806 let _: fn(&crate::ShareRecordingRequest) = |_| {};
807 let _: fn(&crate::ShareRecordingResponse) = |_| {};
808 }
809 }
810
811 #[test]
812 fn record_serializes_with_camel_case_keys() {
813 let r = VoiceCallRecord {
814 source_id: "11111111-1111-4111-8111-111111111111".into(),
815 account_id: "22222222-2222-4222-8222-222222222222".into(),
816 direction: VoiceCallDirection::Inbound,
817 party: "+14155550123".into(),
818 ring_at: "2026-05-16T10:00:00Z".into(),
819 answer_at: Some("2026-05-16T10:00:05Z".into()),
820 end_at: "2026-05-16T10:01:00Z".into(),
821 duration_ms: Some(55_000),
822 disposition: VoiceCallDisposition::Answered,
823 end_reason: VoiceCallEndReason::HangupRemote,
824 error: None,
825 share_visibility: None,
826 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
827 };
828 let s = serde_json::to_string(&r).unwrap();
829 assert!(s.contains("\"sourceId\":"), "{s}");
830 assert!(s.contains("\"accountId\":"), "{s}");
831 assert!(s.contains("\"ringAt\":"), "{s}");
832 assert!(s.contains("\"endAt\":"), "{s}");
833 assert!(s.contains("\"durationMs\":55000"), "{s}");
834 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
836 assert!(
840 s.contains("\"schemaVersion\":1"),
841 "schemaVersion should flatten: {s}"
842 );
843 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
846 }
847
848 #[test]
849 fn record_round_trips_optional_fields() {
850 let raw = r#"{
852 "sourceId": "a",
853 "accountId": "b",
854 "direction": "inbound",
855 "party": "anonymous",
856 "ringAt": "2026-05-16T10:00:00Z",
857 "endAt": "2026-05-16T10:00:30Z",
858 "disposition": "missed",
859 "endReason": "missed"
860 }"#;
861 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
862 assert!(parsed.answer_at.is_none());
863 assert!(parsed.duration_ms.is_none());
864 assert!(parsed.error.is_none());
865 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
866 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
867 }
868
869 #[test]
870 fn query_omits_unset_fields() {
871 let q = VoiceCallsQuery::default();
872 let s = serde_json::to_string(&q).unwrap();
873 assert_eq!(
875 s, "{}",
876 "default query should serialize to empty object: {s}"
877 );
878 }
879
880 #[test]
881 fn enum_round_trip_via_json() {
882 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
886 let s = serde_json::to_string(&d).unwrap();
887 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
888 assert_eq!(d, back);
889 }
890 for d in [
891 VoiceCallDisposition::Answered,
892 VoiceCallDisposition::Missed,
893 VoiceCallDisposition::Rejected,
894 VoiceCallDisposition::Cancelled,
895 VoiceCallDisposition::Failed,
896 ] {
897 let s = serde_json::to_string(&d).unwrap();
898 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
899 assert_eq!(d, back);
900 }
901 for r in [
902 VoiceCallEndReason::HangupLocal,
903 VoiceCallEndReason::HangupRemote,
904 VoiceCallEndReason::RejectedLocal,
905 VoiceCallEndReason::RejectedRemote,
906 VoiceCallEndReason::Missed,
907 VoiceCallEndReason::CancelledLocal,
908 VoiceCallEndReason::ConnectionLost,
909 VoiceCallEndReason::Failed,
910 ] {
911 let s = serde_json::to_string(&r).unwrap();
912 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
913 assert_eq!(r, back);
914 }
915 }
916
917 #[test]
918 fn connection_lost_pins_its_wire_string() {
919 let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
923 assert_eq!(s, "\"connection_lost\"");
924 }
925
926 #[test]
927 fn voice_calls_marker_resource_is_calls() {
928 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
929 }
930
931 #[test]
932 fn record_accepts_unknown_extras_for_forward_compat() {
933 let raw = r#"{
939 "sourceId": "a",
940 "accountId": "b",
941 "direction": "inbound",
942 "party": "anon",
943 "ringAt": "2026-05-16T10:00:00Z",
944 "endAt": "2026-05-16T10:00:30Z",
945 "disposition": "answered",
946 "endReason": "hangup_remote",
947 "schemaVersion": 2,
948 "extras": { "notes": "from staging build" }
949 }"#;
950 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
951 assert_eq!(parsed.envelope.schema_version, Some(2));
952 let extras = parsed.envelope.extras.as_ref().expect("extras present");
953 assert_eq!(extras["notes"], "from staging build");
954 }
955
956 #[test]
957 fn call_record_parses_share_visibility_from_list_response() {
958 let raw = r#"{
961 "sourceId": "a",
962 "accountId": "b",
963 "direction": "outbound",
964 "party": "+14155550123",
965 "ringAt": "2026-05-16T10:00:00Z",
966 "endAt": "2026-05-16T10:00:30Z",
967 "disposition": "answered",
968 "endReason": "hangup_remote",
969 "shareVisibility": "public"
970 }"#;
971 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
972 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
973
974 let restricted = raw.replace("public", "restricted");
975 let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
976 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
977 }
978
979 #[test]
980 fn call_record_unshared_has_no_share_visibility() {
981 let base = r#"{
984 "sourceId": "a",
985 "accountId": "b",
986 "direction": "inbound",
987 "party": "anon",
988 "ringAt": "2026-05-16T10:00:00Z",
989 "endAt": "2026-05-16T10:00:30Z",
990 "disposition": "missed",
991 "endReason": "missed"
992 }"#;
993 let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
994 assert_eq!(parsed.share_visibility, None);
995
996 let with_null = base.replace(
997 r#""endReason": "missed""#,
998 r#""endReason": "missed", "shareVisibility": null"#,
999 );
1000 let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1001 assert_eq!(parsed.share_visibility, None);
1002 }
1003
1004 #[test]
1005 fn synced_call_omits_share_visibility() {
1006 let raw = r#"{
1010 "sourceId": "a",
1011 "accountId": "b",
1012 "direction": "inbound",
1013 "party": "anon",
1014 "ringAt": "2026-05-16T10:00:00Z",
1015 "endAt": "2026-05-16T10:00:30Z",
1016 "disposition": "answered",
1017 "endReason": "hangup_remote"
1018 }"#;
1019 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1020 assert_eq!(parsed.share_visibility, None);
1021 let s = serde_json::to_string(&parsed).unwrap();
1022 assert!(
1023 !s.contains("shareVisibility"),
1024 "sync payload leaked share_visibility: {s}"
1025 );
1026 }
1027
1028 #[test]
1029 fn recording_marker_resource_is_recordings() {
1030 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1033 }
1034
1035 #[test]
1036 fn recording_record_serializes_with_camel_case_and_envelope() {
1037 let r = VoiceRecordingRecord {
1038 source_id: "11111111-1111-4111-8111-111111111111".into(),
1039 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1040 size_bytes: 44 + 64_000,
1041 duration_ms: 2_000,
1042 sample_rate: 8_000,
1043 channels: 2,
1044 created_at: "2026-05-16T10:01:05Z".into(),
1045 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1046 };
1047 let s = serde_json::to_string(&r).unwrap();
1048 assert!(s.contains("\"sourceId\":"), "{s}");
1051 assert!(s.contains("\"callSourceId\":"), "{s}");
1052 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1053 assert!(s.contains("\"durationMs\":2000"), "{s}");
1054 assert!(s.contains("\"sampleRate\":8000"), "{s}");
1055 assert!(s.contains("\"channels\":2"), "{s}");
1056 assert!(s.contains("\"createdAt\":"), "{s}");
1057 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1059 }
1060
1061 #[test]
1062 fn recordings_sync_response_round_trips() {
1063 let raw = r#"{
1068 "accepted": 2,
1069 "skipped": 0,
1070 "items": [
1071 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1072 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1073 ]
1074 }"#;
1075 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1076 assert_eq!(parsed.accepted, 2);
1077 assert_eq!(parsed.items.len(), 2);
1078 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1079 assert!(!parsed.items[0].bytes_uploaded);
1080 assert!(parsed.items[1].bytes_uploaded);
1081 }
1082
1083 #[test]
1084 fn install_heartbeat_request_serializes_with_camel_case_keys() {
1085 let req = InstallHeartbeatRequest {
1086 install_id: "11111111-1111-4111-8111-111111111111".into(),
1087 app_version: "0.0.21".into(),
1088 os: "macos".into(),
1089 os_version: Some("15.5.0".into()),
1090 arch: Some("aarch64".into()),
1091 locale: Some("en-NZ".into()),
1092 };
1093 let s = serde_json::to_string(&req).unwrap();
1094 assert!(s.contains("\"installId\":"), "{s}");
1095 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1096 assert!(s.contains("\"os\":\"macos\""), "{s}");
1097 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1098 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1099 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1100 }
1101
1102 #[test]
1103 fn install_heartbeat_request_omits_absent_optional_fields() {
1104 let req = InstallHeartbeatRequest {
1109 install_id: "x".into(),
1110 app_version: "0.0.21".into(),
1111 os: "linux".into(),
1112 os_version: None,
1113 arch: None,
1114 locale: None,
1115 };
1116 let s = serde_json::to_string(&req).unwrap();
1117 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1118 assert!(!s.contains("arch"), "arch should be omitted: {s}");
1119 assert!(!s.contains("locale"), "locale should be omitted: {s}");
1120 }
1121
1122 #[test]
1123 fn install_heartbeat_response_parses_platform_shape() {
1124 let raw = r#"{
1125 "id": "abc-123",
1126 "installId": "11111111-1111-4111-8111-111111111111",
1127 "appVersion": "0.0.21",
1128 "os": "macos",
1129 "osVersion": "15.5.0",
1130 "arch": "aarch64",
1131 "locale": null,
1132 "firstSeenAt": "2026-05-31T10:00:00.000Z",
1133 "lastSeenAt": "2026-05-31T10:00:00.000Z"
1134 }"#;
1135 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1136 assert_eq!(parsed.id, "abc-123");
1137 assert_eq!(parsed.app_version, "0.0.21");
1138 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1139 assert!(parsed.locale.is_none());
1140 }
1141
1142 #[test]
1143 fn system_info_detect_fills_os_and_arch() {
1144 let sys = SystemInfo::detect();
1148 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1149 assert!(
1150 !sys.arch.is_empty(),
1151 "arch should be a non-empty target string"
1152 );
1153 }
1154
1155 #[test]
1156 fn transcripts_marker_resource_is_transcripts() {
1157 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1158 }
1159
1160 #[test]
1161 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1162 let r = VoiceTranscriptRecord {
1163 source_id: "1".into(),
1164 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1165 channel: VoiceTranscriptChannel::Remote,
1166 ts_ms: 100,
1167 end_ms: 1_500,
1168 text: "hello".into(),
1169 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1170 };
1171 let s = serde_json::to_string(&r).unwrap();
1172 assert!(s.contains("\"sourceId\":"), "{s}");
1173 assert!(s.contains("\"callSourceId\":"), "{s}");
1174 assert!(s.contains("\"channel\":\"remote\""), "{s}");
1177 assert!(s.contains("\"tsMs\":100"), "{s}");
1178 assert!(s.contains("\"endMs\":1500"), "{s}");
1179 assert!(s.contains("\"text\":\"hello\""), "{s}");
1180 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1181 }
1182
1183 #[test]
1184 fn share_visibility_pins_its_wire_strings() {
1185 assert_eq!(
1188 serde_json::to_string(&ShareVisibility::Private).unwrap(),
1189 "\"private\""
1190 );
1191 assert_eq!(
1192 serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1193 "\"restricted\""
1194 );
1195 assert_eq!(
1196 serde_json::to_string(&ShareVisibility::Public).unwrap(),
1197 "\"public\""
1198 );
1199 for v in [
1200 ShareVisibility::Private,
1201 ShareVisibility::Restricted,
1202 ShareVisibility::Public,
1203 ] {
1204 let s = serde_json::to_string(&v).unwrap();
1205 let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1206 assert_eq!(v, back);
1207 }
1208 }
1209
1210 #[test]
1211 fn share_request_serializes_with_camel_case_and_omits_unset() {
1212 let req = ShareRecordingRequest {
1213 recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1214 visibility: ShareVisibility::Public,
1215 invited_emails: None,
1216 party_masking: None,
1217 show_transcript: None,
1218 show_audio: None,
1219 password: None,
1220 expires_at: None,
1221 };
1222 let s = serde_json::to_string(&req).unwrap();
1223 assert!(s.contains("\"recordingSourceId\":"), "{s}");
1224 assert!(s.contains("\"visibility\":\"public\""), "{s}");
1225 assert!(!s.contains("invitedEmails"), "{s}");
1229 assert!(!s.contains("partyMasking"), "{s}");
1230 assert!(!s.contains("showTranscript"), "{s}");
1231 assert!(!s.contains("showAudio"), "{s}");
1232 assert!(!s.contains("password"), "{s}");
1233 assert!(!s.contains("expiresAt"), "{s}");
1234 }
1235
1236 #[test]
1237 fn share_request_serializes_visibility_controls_camel_case() {
1238 let req = ShareRecordingRequest {
1239 recording_source_id: "a".into(),
1240 visibility: ShareVisibility::Public,
1241 invited_emails: None,
1242 party_masking: Some(PartyMasking::Partial),
1243 show_transcript: Some(false),
1244 show_audio: Some(true),
1245 password: None,
1246 expires_at: None,
1247 };
1248 let s = serde_json::to_string(&req).unwrap();
1249 assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1250 assert!(s.contains("\"showTranscript\":false"), "{s}");
1251 assert!(s.contains("\"showAudio\":true"), "{s}");
1252 }
1253
1254 #[test]
1255 fn share_request_carries_invited_emails_for_restricted() {
1256 let req = ShareRecordingRequest {
1257 recording_source_id: "a".into(),
1258 visibility: ShareVisibility::Restricted,
1259 invited_emails: Some(vec!["alex@example.com".into()]),
1260 party_masking: None,
1261 show_transcript: None,
1262 show_audio: None,
1263 password: None,
1264 expires_at: None,
1265 };
1266 let s = serde_json::to_string(&req).unwrap();
1267 assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1268 assert!(
1269 s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1270 "{s}"
1271 );
1272 }
1273
1274 #[test]
1275 fn share_response_parses_platform_shape() {
1276 let raw = r#"{
1277 "visibility": "public",
1278 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1279 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1280 "sharedAt": "2026-06-19T10:00:00.000Z"
1281 }"#;
1282 let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1283 assert_eq!(parsed.visibility, ShareVisibility::Public);
1284 assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1285 assert!(parsed.share_url.ends_with(&parsed.token));
1286 }
1287
1288 #[test]
1289 fn share_state_parses_restricted_with_invited_emails() {
1290 let raw = r#"{
1293 "visibility": "restricted",
1294 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1295 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1296 "sharedAt": "2026-06-19T10:00:00.000Z",
1297 "invitedEmails": ["bob@example.com", "carol@example.com"],
1298 "partyMasking": "full",
1299 "showTranscript": true,
1300 "showAudio": false
1301 }"#;
1302 let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1303 assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1304 assert_eq!(
1305 parsed.invited_emails.as_deref(),
1306 Some(
1307 [
1308 "bob@example.com".to_string(),
1309 "carol@example.com".to_string()
1310 ]
1311 .as_slice()
1312 )
1313 );
1314 assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1316 assert_eq!(parsed.show_transcript, Some(true));
1317 assert_eq!(parsed.show_audio, Some(false));
1318 }
1319
1320 #[test]
1321 fn share_state_parses_private_with_fields_absent() {
1322 let parsed: ShareStateResponse =
1325 serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1326 assert_eq!(parsed.visibility, ShareVisibility::Private);
1327 assert!(parsed.token.is_none());
1328 assert!(parsed.share_url.is_none());
1329 assert!(parsed.shared_at.is_none());
1330 assert!(parsed.invited_emails.is_none());
1331 }
1332
1333 #[test]
1334 fn share_request_rejects_empty_source_id_before_hitting_network() {
1335 let req = ShareRecordingRequest {
1338 recording_source_id: String::new(),
1339 visibility: ShareVisibility::Private,
1340 invited_emails: None,
1341 party_masking: None,
1342 show_transcript: None,
1343 show_audio: None,
1344 password: None,
1345 expires_at: None,
1346 };
1347 assert!(req.recording_source_id.is_empty());
1351 }
1352
1353 fn sample_account() -> VoiceAccountRecord {
1356 VoiceAccountRecord {
1357 source_id: "11111111-1111-4111-8111-111111111111".into(),
1358 enabled: true,
1359 display_name: "Work line".into(),
1360 username: "alice".into(),
1361 domain: "sip.example.com".into(),
1362 auth_username: Some("alice-auth".into()),
1363 server: Some("sip.example.com".into()),
1364 port: Some(5060),
1365 transport: VoiceTransport::Udp,
1366 register_expires: 60,
1367 keepalive_secs: Some(50),
1368 disclosure_enabled: true,
1369 updated_at: "2026-06-20T10:00:00Z".into(),
1370 deleted_at: None,
1371 envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1372 }
1373 }
1374
1375 #[test]
1376 fn accounts_marker_resource_is_accounts() {
1377 assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1380 }
1381
1382 #[test]
1383 fn account_record_serializes_with_camel_case_and_envelope() {
1384 let s = serde_json::to_string(&sample_account()).unwrap();
1385 assert!(s.contains("\"sourceId\":"), "{s}");
1388 assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1389 assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1390 assert!(s.contains("\"registerExpires\":60"), "{s}");
1391 assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1392 assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1393 assert!(s.contains("\"transport\":\"udp\""), "{s}");
1394 assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1395 assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1397 assert!(!s.contains("password"), "no password field: {s}");
1399 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1401 }
1402
1403 #[test]
1404 fn account_tombstone_serializes_deleted_at() {
1405 let mut r = sample_account();
1408 r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1409 let s = serde_json::to_string(&r).unwrap();
1410 assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1411 }
1412
1413 #[test]
1414 fn account_record_round_trips_optional_fields() {
1415 let raw = r#"{
1418 "sourceId": "a",
1419 "enabled": false,
1420 "displayName": "Cheap trunk",
1421 "username": "u",
1422 "domain": "d",
1423 "transport": "tcp",
1424 "registerExpires": 120,
1425 "disclosureEnabled": false,
1426 "updatedAt": "2026-06-20T10:00:00Z"
1427 }"#;
1428 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1429 assert!(!parsed.enabled);
1430 assert!(parsed.auth_username.is_none());
1431 assert!(parsed.server.is_none());
1432 assert!(parsed.port.is_none());
1433 assert!(parsed.keepalive_secs.is_none());
1434 assert!(parsed.deleted_at.is_none());
1435 assert_eq!(parsed.transport, VoiceTransport::Tcp);
1436 assert_eq!(parsed.register_expires, 120);
1437 }
1438
1439 #[test]
1440 fn voice_transport_round_trips_via_json() {
1441 for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1442 let s = serde_json::to_string(&t).unwrap();
1443 let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1444 assert_eq!(t, back);
1445 }
1446 assert_eq!(
1449 serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1450 "\"udp\""
1451 );
1452 assert_eq!(
1453 serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1454 "\"tcp\""
1455 );
1456 }
1457
1458 #[test]
1459 fn accounts_query_omits_unset_and_serializes_include_deleted() {
1460 let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1461 assert_eq!(empty, "{}", "default query should be empty: {empty}");
1462 let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1463 include_deleted: Some(true),
1464 })
1465 .unwrap();
1466 assert!(
1467 with_deleted.contains("\"includeDeleted\":true"),
1468 "{with_deleted}"
1469 );
1470 }
1471
1472 #[test]
1473 fn account_record_accepts_unknown_extras_for_forward_compat() {
1474 let raw = r#"{
1477 "sourceId": "a",
1478 "enabled": true,
1479 "displayName": "x",
1480 "username": "u",
1481 "domain": "d",
1482 "transport": "udp",
1483 "registerExpires": 60,
1484 "disclosureEnabled": true,
1485 "updatedAt": "2026-06-20T10:00:00Z",
1486 "schemaVersion": 2,
1487 "extras": { "ringtone": "classic" }
1488 }"#;
1489 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1490 assert_eq!(parsed.envelope.schema_version, Some(2));
1491 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1492 assert_eq!(extras["ringtone"], "classic");
1493 }
1494}