1use serde::{Deserialize, Serialize};
15
16use crate::client::Client;
17use crate::error::{Error, Result};
18use crate::sign::ReleaseCredential;
19use crate::sync::{stamp_schema_version, HasSyncEnvelope, SyncEndpoint, SyncEnvelope, SyncRequest};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum VoiceCallDirection {
27 Inbound,
28 Outbound,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum VoiceCallDisposition {
37 Answered,
38 Missed,
39 Rejected,
40 Cancelled,
41 Failed,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum VoiceCallEndReason {
54 HangupLocal,
55 HangupRemote,
56 RejectedLocal,
57 RejectedRemote,
58 Missed,
59 CancelledLocal,
60 TransferredLocal,
67 ConnectionLost,
73 Failed,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct VoiceCallRecord {
87 pub source_id: String,
90 pub account_id: String,
92 pub direction: VoiceCallDirection,
93 pub party: String,
96 pub ring_at: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub answer_at: Option<String>,
102 pub end_at: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub duration_ms: Option<i64>,
109 pub disposition: VoiceCallDisposition,
110 pub end_reason: VoiceCallEndReason,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub error: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub share_visibility: Option<ShareVisibility>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub transfer_target: Option<String>,
129 #[serde(flatten, default)]
135 pub envelope: SyncEnvelope,
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct VoiceCallsQuery {
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub before: Option<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub limit: Option<u32>,
149}
150
151pub struct VoiceCalls;
155
156impl SyncEndpoint for VoiceCalls {
157 const RESOURCE: &'static str = "calls";
158 type Record = VoiceCallRecord;
159 type Query = VoiceCallsQuery;
160}
161
162impl HasSyncEnvelope for VoiceCallRecord {
163 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
164 &mut self.envelope
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct VoiceRecordingRecord {
185 pub source_id: String,
188 pub call_source_id: String,
192 pub size_bytes: u64,
196 pub duration_ms: u64,
197 pub sample_rate: u32,
198 pub channels: u16,
199 pub created_at: String,
203 #[serde(flatten, default)]
204 pub envelope: SyncEnvelope,
205}
206
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct VoiceRecordingsQuery {
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub before: Option<String>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub limit: Option<u32>,
216}
217
218pub struct VoiceRecordings;
226
227impl SyncEndpoint for VoiceRecordings {
228 const RESOURCE: &'static str = "recordings";
229 type Record = VoiceRecordingRecord;
230 type Query = VoiceRecordingsQuery;
231}
232
233impl HasSyncEnvelope for VoiceRecordingRecord {
234 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
235 &mut self.envelope
236 }
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct VoiceRecordingSyncItem {
248 pub source_id: String,
249 pub r2_key: String,
250 pub bytes_uploaded: bool,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct VoiceRecordingsSyncResponse {
258 pub accepted: u32,
259 pub skipped: u32,
260 pub items: Vec<VoiceRecordingSyncItem>,
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269pub enum VoiceTranscriptChannel {
270 Local,
272 Remote,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct VoiceTranscriptRecord {
283 pub source_id: String,
287 pub call_source_id: String,
289 pub channel: VoiceTranscriptChannel,
290 pub ts_ms: i64,
293 pub end_ms: i64,
295 pub text: String,
297 #[serde(flatten, default)]
298 pub envelope: SyncEnvelope,
299}
300
301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub struct VoiceTranscriptsQuery {
306 pub call_source_id: String,
307}
308
309pub struct VoiceTranscripts;
311
312impl SyncEndpoint for VoiceTranscripts {
313 const RESOURCE: &'static str = "transcripts";
314 type Record = VoiceTranscriptRecord;
315 type Query = VoiceTranscriptsQuery;
316}
317
318impl HasSyncEnvelope for VoiceTranscriptRecord {
319 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
320 &mut self.envelope
321 }
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "snake_case")]
330pub enum VoiceTransport {
331 Udp,
332 Tcp,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub struct VoiceAccountRecord {
358 pub source_id: String,
363 pub enabled: bool,
366 pub display_name: String,
367 pub username: String,
368 pub domain: String,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub auth_username: Option<String>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub server: Option<String>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub port: Option<u16>,
375 pub transport: VoiceTransport,
376 pub register_expires: u32,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub keepalive_secs: Option<u32>,
379 pub disclosure_enabled: bool,
382 pub updated_at: String,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub deleted_at: Option<String>,
394 #[serde(flatten, default)]
396 pub envelope: SyncEnvelope,
397}
398
399#[derive(Debug, Clone, Default, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct VoiceAccountsQuery {
403 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub include_deleted: Option<bool>,
409}
410
411pub struct VoiceAccounts;
420
421impl SyncEndpoint for VoiceAccounts {
422 const RESOURCE: &'static str = "accounts";
423 type Record = VoiceAccountRecord;
424 type Query = VoiceAccountsQuery;
425}
426
427impl HasSyncEnvelope for VoiceAccountRecord {
428 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
429 &mut self.envelope
430 }
431}
432
433#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct SystemInfo {
454 pub os: String,
456 pub os_version: Option<String>,
459 pub arch: String,
461 pub locale: Option<String>,
464}
465
466impl SystemInfo {
467 pub fn detect() -> Self {
470 let os_version = match os_info::get().version() {
471 os_info::Version::Unknown => None,
472 v => Some(v.to_string()),
473 };
474 SystemInfo {
475 os: std::env::consts::OS.to_string(),
476 os_version,
477 arch: std::env::consts::ARCH.to_string(),
478 locale: sys_locale::get_locale(),
479 }
480 }
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
487#[serde(rename_all = "camelCase")]
488pub struct InstallHeartbeatRequest {
489 pub install_id: String,
491 pub app_version: String,
494 pub os: String,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub os_version: Option<String>,
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub arch: Option<String>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub locale: Option<String>,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505#[serde(rename_all = "camelCase")]
506pub struct InstallHeartbeatResponse {
507 pub id: String,
508 pub install_id: String,
509 pub app_version: String,
510 pub os: String,
511 pub os_version: Option<String>,
512 pub arch: Option<String>,
513 pub locale: Option<String>,
514 pub first_seen_at: String,
515 pub last_seen_at: String,
516}
517
518impl Client {
519 pub async fn install_heartbeat(
536 base_url: &str,
537 install_id: &str,
538 app_version: &str,
539 cred: &ReleaseCredential,
540 ) -> Result<InstallHeartbeatResponse> {
541 let sys = SystemInfo::detect();
542 let body = InstallHeartbeatRequest {
543 install_id: install_id.to_string(),
544 app_version: app_version.to_string(),
545 os: sys.os,
546 os_version: sys.os_version,
547 arch: Some(sys.arch),
548 locale: sys.locale,
549 };
550 Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
551 base_url,
552 "/api/voice/installs/heartbeat",
553 &body,
554 cred,
555 )
556 .await
557 }
558}
559
560impl Client {
575 pub async fn sync_recordings(
583 &self,
584 items: &[VoiceRecordingRecord],
585 ) -> Result<VoiceRecordingsSyncResponse> {
586 let stamped = stamp_schema_version::<VoiceRecordings>(items);
587 let body = SyncRequest { items: stamped };
588 self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
589 .await
590 }
591
592 pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
603 if source_id.is_empty() {
604 return Err(Error::BadRequest("source_id must not be empty".into()));
605 }
606 let path = format!("/api/voice/recordings/{source_id}/bytes");
607 self.put_raw_bytes(&path, "audio/wav", bytes).await
608 }
609}
610
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
631#[serde(rename_all = "snake_case")]
632pub enum ShareVisibility {
633 Private,
634 Restricted,
635 Public,
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
650#[serde(rename_all = "snake_case")]
651pub enum PartyMasking {
652 Full,
653 Partial,
654 None,
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize)]
661#[serde(rename_all = "camelCase")]
662pub struct ShareRecordingRequest {
663 pub recording_source_id: String,
666 pub visibility: ShareVisibility,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub invited_emails: Option<Vec<String>>,
671 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub party_masking: Option<PartyMasking>,
680 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub show_transcript: Option<bool>,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub show_audio: Option<bool>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub allow_download: Option<bool>,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub default_mute_local: Option<bool>,
701 #[serde(default, skip_serializing_if = "Option::is_none")]
702 pub default_mute_remote: Option<bool>,
703 #[serde(default, skip_serializing_if = "Option::is_none")]
705 pub password: Option<String>,
706 #[serde(default, skip_serializing_if = "Option::is_none")]
708 pub expires_at: Option<String>,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
716#[serde(rename_all = "camelCase")]
717pub struct ShareRecordingResponse {
718 pub visibility: ShareVisibility,
719 pub token: String,
720 pub share_url: String,
721 pub shared_at: String,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub party_masking: Option<PartyMasking>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub show_transcript: Option<bool>,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub show_audio: Option<bool>,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub allow_download: Option<bool>,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub default_mute_local: Option<bool>,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub default_mute_remote: Option<bool>,
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize)]
757#[serde(rename_all = "camelCase")]
758pub struct ShareStateResponse {
759 pub visibility: ShareVisibility,
760 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub token: Option<String>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub share_url: Option<String>,
765 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub shared_at: Option<String>,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub invited_emails: Option<Vec<String>>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
775 pub party_masking: Option<PartyMasking>,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
777 pub show_transcript: Option<bool>,
778 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub show_audio: Option<bool>,
780 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub allow_download: Option<bool>,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub default_mute_local: Option<bool>,
788 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub default_mute_remote: Option<bool>,
790}
791
792impl Client {
793 pub async fn share_recording(
801 &self,
802 req: &ShareRecordingRequest,
803 ) -> Result<ShareRecordingResponse> {
804 if req.recording_source_id.is_empty() {
805 return Err(Error::BadRequest(
806 "recording_source_id must not be empty".into(),
807 ));
808 }
809 let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
810 self.post_json::<ShareRecordingResponse, _>(&path, req)
811 .await
812 }
813
814 pub async fn get_recording_share(
820 &self,
821 recording_source_id: &str,
822 ) -> Result<ShareStateResponse> {
823 if recording_source_id.is_empty() {
824 return Err(Error::BadRequest(
825 "recording_source_id must not be empty".into(),
826 ));
827 }
828 let path = format!("/api/voice/recordings/{recording_source_id}/share");
829 self.get_json::<ShareStateResponse>(&path).await
830 }
831
832 pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
835 if recording_source_id.is_empty() {
836 return Err(Error::BadRequest(
837 "recording_source_id must not be empty".into(),
838 ));
839 }
840 let path = format!("/api/voice/recordings/{recording_source_id}/share");
841 self.delete(&path).await
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848
849 #[test]
850 fn share_visibility_types_are_reachable_from_the_crate_root() {
851 #[allow(dead_code)]
858 fn _reachable() {
859 let _: Option<crate::PartyMasking> = Some(crate::PartyMasking::Partial);
860 let _: Option<crate::ShareVisibility> = Some(crate::ShareVisibility::Public);
861 let _: fn(&crate::ShareRecordingRequest) = |_| {};
862 let _: fn(&crate::ShareRecordingResponse) = |_| {};
863 }
864 }
865
866 #[test]
867 fn record_serializes_with_camel_case_keys() {
868 let r = VoiceCallRecord {
869 source_id: "11111111-1111-4111-8111-111111111111".into(),
870 account_id: "22222222-2222-4222-8222-222222222222".into(),
871 direction: VoiceCallDirection::Inbound,
872 party: "+14155550123".into(),
873 ring_at: "2026-05-16T10:00:00Z".into(),
874 answer_at: Some("2026-05-16T10:00:05Z".into()),
875 end_at: "2026-05-16T10:01:00Z".into(),
876 duration_ms: Some(55_000),
877 disposition: VoiceCallDisposition::Answered,
878 end_reason: VoiceCallEndReason::HangupRemote,
879 error: None,
880 share_visibility: None,
881 transfer_target: None,
882 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
883 };
884 let s = serde_json::to_string(&r).unwrap();
885 assert!(s.contains("\"sourceId\":"), "{s}");
886 assert!(s.contains("\"accountId\":"), "{s}");
887 assert!(s.contains("\"ringAt\":"), "{s}");
888 assert!(s.contains("\"endAt\":"), "{s}");
889 assert!(s.contains("\"durationMs\":55000"), "{s}");
890 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
892 assert!(
895 !s.contains("\"transferTarget\""),
896 "transferTarget should be omitted: {s}"
897 );
898 assert!(
902 s.contains("\"schemaVersion\":1"),
903 "schemaVersion should flatten: {s}"
904 );
905 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
908 }
909
910 #[test]
911 fn record_round_trips_optional_fields() {
912 let raw = r#"{
914 "sourceId": "a",
915 "accountId": "b",
916 "direction": "inbound",
917 "party": "anonymous",
918 "ringAt": "2026-05-16T10:00:00Z",
919 "endAt": "2026-05-16T10:00:30Z",
920 "disposition": "missed",
921 "endReason": "missed"
922 }"#;
923 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
924 assert!(parsed.answer_at.is_none());
925 assert!(parsed.duration_ms.is_none());
926 assert!(parsed.error.is_none());
927 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
928 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
929 }
930
931 #[test]
932 fn query_omits_unset_fields() {
933 let q = VoiceCallsQuery::default();
934 let s = serde_json::to_string(&q).unwrap();
935 assert_eq!(
937 s, "{}",
938 "default query should serialize to empty object: {s}"
939 );
940 }
941
942 #[test]
943 fn enum_round_trip_via_json() {
944 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
948 let s = serde_json::to_string(&d).unwrap();
949 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
950 assert_eq!(d, back);
951 }
952 for d in [
953 VoiceCallDisposition::Answered,
954 VoiceCallDisposition::Missed,
955 VoiceCallDisposition::Rejected,
956 VoiceCallDisposition::Cancelled,
957 VoiceCallDisposition::Failed,
958 ] {
959 let s = serde_json::to_string(&d).unwrap();
960 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
961 assert_eq!(d, back);
962 }
963 for r in [
964 VoiceCallEndReason::HangupLocal,
965 VoiceCallEndReason::HangupRemote,
966 VoiceCallEndReason::RejectedLocal,
967 VoiceCallEndReason::RejectedRemote,
968 VoiceCallEndReason::Missed,
969 VoiceCallEndReason::CancelledLocal,
970 VoiceCallEndReason::TransferredLocal,
971 VoiceCallEndReason::ConnectionLost,
972 VoiceCallEndReason::Failed,
973 ] {
974 let s = serde_json::to_string(&r).unwrap();
975 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
976 assert_eq!(r, back);
977 }
978 }
979
980 #[test]
981 fn connection_lost_pins_its_wire_string() {
982 let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
986 assert_eq!(s, "\"connection_lost\"");
987 }
988
989 #[test]
990 fn transferred_local_pins_its_wire_string() {
991 let s = serde_json::to_string(&VoiceCallEndReason::TransferredLocal).unwrap();
995 assert_eq!(s, "\"transferred_local\"");
996 }
997
998 #[test]
999 fn record_round_trips_transfer_target() {
1000 let raw = r#"{
1004 "sourceId": "a",
1005 "accountId": "b",
1006 "direction": "inbound",
1007 "party": "Alice <sip:alice@example.com>",
1008 "ringAt": "2026-06-28T10:00:00Z",
1009 "answerAt": "2026-06-28T10:00:05Z",
1010 "endAt": "2026-06-28T10:00:30Z",
1011 "durationMs": 25000,
1012 "disposition": "answered",
1013 "endReason": "transferred_local",
1014 "transferTarget": "1002"
1015 }"#;
1016 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1017 assert_eq!(parsed.end_reason, VoiceCallEndReason::TransferredLocal);
1018 assert_eq!(parsed.transfer_target.as_deref(), Some("1002"));
1019 let s = serde_json::to_string(&parsed).unwrap();
1021 assert!(s.contains("\"transferTarget\":\"1002\""), "{s}");
1022 }
1023
1024 #[test]
1025 fn voice_calls_marker_resource_is_calls() {
1026 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
1027 }
1028
1029 #[test]
1030 fn record_accepts_unknown_extras_for_forward_compat() {
1031 let raw = r#"{
1037 "sourceId": "a",
1038 "accountId": "b",
1039 "direction": "inbound",
1040 "party": "anon",
1041 "ringAt": "2026-05-16T10:00:00Z",
1042 "endAt": "2026-05-16T10:00:30Z",
1043 "disposition": "answered",
1044 "endReason": "hangup_remote",
1045 "schemaVersion": 2,
1046 "extras": { "notes": "from staging build" }
1047 }"#;
1048 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1049 assert_eq!(parsed.envelope.schema_version, Some(2));
1050 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1051 assert_eq!(extras["notes"], "from staging build");
1052 }
1053
1054 #[test]
1055 fn call_record_parses_share_visibility_from_list_response() {
1056 let raw = r#"{
1059 "sourceId": "a",
1060 "accountId": "b",
1061 "direction": "outbound",
1062 "party": "+14155550123",
1063 "ringAt": "2026-05-16T10:00:00Z",
1064 "endAt": "2026-05-16T10:00:30Z",
1065 "disposition": "answered",
1066 "endReason": "hangup_remote",
1067 "shareVisibility": "public"
1068 }"#;
1069 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1070 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Public));
1071
1072 let restricted = raw.replace("public", "restricted");
1073 let parsed: VoiceCallRecord = serde_json::from_str(&restricted).unwrap();
1074 assert_eq!(parsed.share_visibility, Some(ShareVisibility::Restricted));
1075 }
1076
1077 #[test]
1078 fn call_record_unshared_has_no_share_visibility() {
1079 let base = r#"{
1082 "sourceId": "a",
1083 "accountId": "b",
1084 "direction": "inbound",
1085 "party": "anon",
1086 "ringAt": "2026-05-16T10:00:00Z",
1087 "endAt": "2026-05-16T10:00:30Z",
1088 "disposition": "missed",
1089 "endReason": "missed"
1090 }"#;
1091 let parsed: VoiceCallRecord = serde_json::from_str(base).unwrap();
1092 assert_eq!(parsed.share_visibility, None);
1093
1094 let with_null = base.replace(
1095 r#""endReason": "missed""#,
1096 r#""endReason": "missed", "shareVisibility": null"#,
1097 );
1098 let parsed: VoiceCallRecord = serde_json::from_str(&with_null).unwrap();
1099 assert_eq!(parsed.share_visibility, None);
1100 }
1101
1102 #[test]
1103 fn synced_call_omits_share_visibility() {
1104 let raw = r#"{
1108 "sourceId": "a",
1109 "accountId": "b",
1110 "direction": "inbound",
1111 "party": "anon",
1112 "ringAt": "2026-05-16T10:00:00Z",
1113 "endAt": "2026-05-16T10:00:30Z",
1114 "disposition": "answered",
1115 "endReason": "hangup_remote"
1116 }"#;
1117 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
1118 assert_eq!(parsed.share_visibility, None);
1119 let s = serde_json::to_string(&parsed).unwrap();
1120 assert!(
1121 !s.contains("shareVisibility"),
1122 "sync payload leaked share_visibility: {s}"
1123 );
1124 }
1125
1126 #[test]
1127 fn recording_marker_resource_is_recordings() {
1128 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
1131 }
1132
1133 #[test]
1134 fn recording_record_serializes_with_camel_case_and_envelope() {
1135 let r = VoiceRecordingRecord {
1136 source_id: "11111111-1111-4111-8111-111111111111".into(),
1137 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1138 size_bytes: 44 + 64_000,
1139 duration_ms: 2_000,
1140 sample_rate: 8_000,
1141 channels: 2,
1142 created_at: "2026-05-16T10:01:05Z".into(),
1143 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
1144 };
1145 let s = serde_json::to_string(&r).unwrap();
1146 assert!(s.contains("\"sourceId\":"), "{s}");
1149 assert!(s.contains("\"callSourceId\":"), "{s}");
1150 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
1151 assert!(s.contains("\"durationMs\":2000"), "{s}");
1152 assert!(s.contains("\"sampleRate\":8000"), "{s}");
1153 assert!(s.contains("\"channels\":2"), "{s}");
1154 assert!(s.contains("\"createdAt\":"), "{s}");
1155 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1157 }
1158
1159 #[test]
1160 fn recordings_sync_response_round_trips() {
1161 let raw = r#"{
1166 "accepted": 2,
1167 "skipped": 0,
1168 "items": [
1169 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
1170 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
1171 ]
1172 }"#;
1173 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
1174 assert_eq!(parsed.accepted, 2);
1175 assert_eq!(parsed.items.len(), 2);
1176 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
1177 assert!(!parsed.items[0].bytes_uploaded);
1178 assert!(parsed.items[1].bytes_uploaded);
1179 }
1180
1181 #[test]
1182 fn install_heartbeat_request_serializes_with_camel_case_keys() {
1183 let req = InstallHeartbeatRequest {
1184 install_id: "11111111-1111-4111-8111-111111111111".into(),
1185 app_version: "0.0.21".into(),
1186 os: "macos".into(),
1187 os_version: Some("15.5.0".into()),
1188 arch: Some("aarch64".into()),
1189 locale: Some("en-NZ".into()),
1190 };
1191 let s = serde_json::to_string(&req).unwrap();
1192 assert!(s.contains("\"installId\":"), "{s}");
1193 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
1194 assert!(s.contains("\"os\":\"macos\""), "{s}");
1195 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
1196 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
1197 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
1198 }
1199
1200 #[test]
1201 fn install_heartbeat_request_omits_absent_optional_fields() {
1202 let req = InstallHeartbeatRequest {
1207 install_id: "x".into(),
1208 app_version: "0.0.21".into(),
1209 os: "linux".into(),
1210 os_version: None,
1211 arch: None,
1212 locale: None,
1213 };
1214 let s = serde_json::to_string(&req).unwrap();
1215 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
1216 assert!(!s.contains("arch"), "arch should be omitted: {s}");
1217 assert!(!s.contains("locale"), "locale should be omitted: {s}");
1218 }
1219
1220 #[test]
1221 fn install_heartbeat_response_parses_platform_shape() {
1222 let raw = r#"{
1223 "id": "abc-123",
1224 "installId": "11111111-1111-4111-8111-111111111111",
1225 "appVersion": "0.0.21",
1226 "os": "macos",
1227 "osVersion": "15.5.0",
1228 "arch": "aarch64",
1229 "locale": null,
1230 "firstSeenAt": "2026-05-31T10:00:00.000Z",
1231 "lastSeenAt": "2026-05-31T10:00:00.000Z"
1232 }"#;
1233 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
1234 assert_eq!(parsed.id, "abc-123");
1235 assert_eq!(parsed.app_version, "0.0.21");
1236 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
1237 assert!(parsed.locale.is_none());
1238 }
1239
1240 #[test]
1241 fn system_info_detect_fills_os_and_arch() {
1242 let sys = SystemInfo::detect();
1246 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1247 assert!(
1248 !sys.arch.is_empty(),
1249 "arch should be a non-empty target string"
1250 );
1251 }
1252
1253 #[test]
1254 fn transcripts_marker_resource_is_transcripts() {
1255 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1256 }
1257
1258 #[test]
1259 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1260 let r = VoiceTranscriptRecord {
1261 source_id: "1".into(),
1262 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1263 channel: VoiceTranscriptChannel::Remote,
1264 ts_ms: 100,
1265 end_ms: 1_500,
1266 text: "hello".into(),
1267 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1268 };
1269 let s = serde_json::to_string(&r).unwrap();
1270 assert!(s.contains("\"sourceId\":"), "{s}");
1271 assert!(s.contains("\"callSourceId\":"), "{s}");
1272 assert!(s.contains("\"channel\":\"remote\""), "{s}");
1275 assert!(s.contains("\"tsMs\":100"), "{s}");
1276 assert!(s.contains("\"endMs\":1500"), "{s}");
1277 assert!(s.contains("\"text\":\"hello\""), "{s}");
1278 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1279 }
1280
1281 #[test]
1282 fn share_visibility_pins_its_wire_strings() {
1283 assert_eq!(
1286 serde_json::to_string(&ShareVisibility::Private).unwrap(),
1287 "\"private\""
1288 );
1289 assert_eq!(
1290 serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1291 "\"restricted\""
1292 );
1293 assert_eq!(
1294 serde_json::to_string(&ShareVisibility::Public).unwrap(),
1295 "\"public\""
1296 );
1297 for v in [
1298 ShareVisibility::Private,
1299 ShareVisibility::Restricted,
1300 ShareVisibility::Public,
1301 ] {
1302 let s = serde_json::to_string(&v).unwrap();
1303 let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1304 assert_eq!(v, back);
1305 }
1306 }
1307
1308 #[test]
1309 fn share_request_serializes_with_camel_case_and_omits_unset() {
1310 let req = ShareRecordingRequest {
1311 recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1312 visibility: ShareVisibility::Public,
1313 invited_emails: None,
1314 party_masking: None,
1315 show_transcript: None,
1316 show_audio: None,
1317 allow_download: None,
1318 default_mute_local: None,
1319 default_mute_remote: None,
1320 password: None,
1321 expires_at: None,
1322 };
1323 let s = serde_json::to_string(&req).unwrap();
1324 assert!(s.contains("\"recordingSourceId\":"), "{s}");
1325 assert!(s.contains("\"visibility\":\"public\""), "{s}");
1326 assert!(!s.contains("invitedEmails"), "{s}");
1330 assert!(!s.contains("partyMasking"), "{s}");
1331 assert!(!s.contains("showTranscript"), "{s}");
1332 assert!(!s.contains("showAudio"), "{s}");
1333 assert!(!s.contains("allowDownload"), "{s}");
1334 assert!(!s.contains("defaultMuteLocal"), "{s}");
1335 assert!(!s.contains("defaultMuteRemote"), "{s}");
1336 assert!(!s.contains("password"), "{s}");
1337 assert!(!s.contains("expiresAt"), "{s}");
1338 }
1339
1340 #[test]
1341 fn share_request_serializes_visibility_controls_camel_case() {
1342 let req = ShareRecordingRequest {
1343 recording_source_id: "a".into(),
1344 visibility: ShareVisibility::Public,
1345 invited_emails: None,
1346 party_masking: Some(PartyMasking::Partial),
1347 show_transcript: Some(false),
1348 show_audio: Some(true),
1349 allow_download: Some(true),
1350 default_mute_local: Some(false),
1351 default_mute_remote: Some(true),
1352 password: None,
1353 expires_at: None,
1354 };
1355 let s = serde_json::to_string(&req).unwrap();
1356 assert!(s.contains("\"partyMasking\":\"partial\""), "{s}");
1357 assert!(s.contains("\"showTranscript\":false"), "{s}");
1358 assert!(s.contains("\"showAudio\":true"), "{s}");
1359 assert!(s.contains("\"allowDownload\":true"), "{s}");
1360 assert!(s.contains("\"defaultMuteLocal\":false"), "{s}");
1363 assert!(s.contains("\"defaultMuteRemote\":true"), "{s}");
1364 }
1365
1366 #[test]
1367 fn share_request_carries_invited_emails_for_restricted() {
1368 let req = ShareRecordingRequest {
1369 recording_source_id: "a".into(),
1370 visibility: ShareVisibility::Restricted,
1371 invited_emails: Some(vec!["alex@example.com".into()]),
1372 party_masking: None,
1373 show_transcript: None,
1374 show_audio: None,
1375 allow_download: None,
1376 default_mute_local: None,
1377 default_mute_remote: None,
1378 password: None,
1379 expires_at: None,
1380 };
1381 let s = serde_json::to_string(&req).unwrap();
1382 assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1383 assert!(
1384 s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1385 "{s}"
1386 );
1387 }
1388
1389 #[test]
1390 fn share_response_parses_platform_shape() {
1391 let raw = r#"{
1392 "visibility": "public",
1393 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1394 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1395 "sharedAt": "2026-06-19T10:00:00.000Z"
1396 }"#;
1397 let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1398 assert_eq!(parsed.visibility, ShareVisibility::Public);
1399 assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1400 assert!(parsed.share_url.ends_with(&parsed.token));
1401 }
1402
1403 #[test]
1404 fn share_state_parses_restricted_with_invited_emails() {
1405 let raw = r#"{
1408 "visibility": "restricted",
1409 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1410 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1411 "sharedAt": "2026-06-19T10:00:00.000Z",
1412 "invitedEmails": ["bob@example.com", "carol@example.com"],
1413 "partyMasking": "full",
1414 "showTranscript": true,
1415 "showAudio": false,
1416 "allowDownload": false,
1417 "defaultMuteLocal": false,
1418 "defaultMuteRemote": true
1419 }"#;
1420 let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1421 assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1422 assert_eq!(
1423 parsed.invited_emails.as_deref(),
1424 Some(
1425 [
1426 "bob@example.com".to_string(),
1427 "carol@example.com".to_string()
1428 ]
1429 .as_slice()
1430 )
1431 );
1432 assert_eq!(parsed.party_masking, Some(PartyMasking::Full));
1434 assert_eq!(parsed.show_transcript, Some(true));
1435 assert_eq!(parsed.show_audio, Some(false));
1436 assert_eq!(parsed.allow_download, Some(false));
1438 assert_eq!(parsed.default_mute_local, Some(false));
1440 assert_eq!(parsed.default_mute_remote, Some(true));
1441 }
1442
1443 #[test]
1444 fn share_state_parses_private_with_fields_absent() {
1445 let parsed: ShareStateResponse =
1448 serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1449 assert_eq!(parsed.visibility, ShareVisibility::Private);
1450 assert!(parsed.token.is_none());
1451 assert!(parsed.share_url.is_none());
1452 assert!(parsed.shared_at.is_none());
1453 assert!(parsed.invited_emails.is_none());
1454 }
1455
1456 #[test]
1457 fn share_request_rejects_empty_source_id_before_hitting_network() {
1458 let req = ShareRecordingRequest {
1461 recording_source_id: String::new(),
1462 visibility: ShareVisibility::Private,
1463 invited_emails: None,
1464 party_masking: None,
1465 show_transcript: None,
1466 show_audio: None,
1467 allow_download: None,
1468 default_mute_local: None,
1469 default_mute_remote: None,
1470 password: None,
1471 expires_at: None,
1472 };
1473 assert!(req.recording_source_id.is_empty());
1477 }
1478
1479 fn sample_account() -> VoiceAccountRecord {
1482 VoiceAccountRecord {
1483 source_id: "11111111-1111-4111-8111-111111111111".into(),
1484 enabled: true,
1485 display_name: "Work line".into(),
1486 username: "alice".into(),
1487 domain: "sip.example.com".into(),
1488 auth_username: Some("alice-auth".into()),
1489 server: Some("sip.example.com".into()),
1490 port: Some(5060),
1491 transport: VoiceTransport::Udp,
1492 register_expires: 60,
1493 keepalive_secs: Some(50),
1494 disclosure_enabled: true,
1495 updated_at: "2026-06-20T10:00:00Z".into(),
1496 deleted_at: None,
1497 envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1498 }
1499 }
1500
1501 #[test]
1502 fn accounts_marker_resource_is_accounts() {
1503 assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1506 }
1507
1508 #[test]
1509 fn account_record_serializes_with_camel_case_and_envelope() {
1510 let s = serde_json::to_string(&sample_account()).unwrap();
1511 assert!(s.contains("\"sourceId\":"), "{s}");
1514 assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1515 assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1516 assert!(s.contains("\"registerExpires\":60"), "{s}");
1517 assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1518 assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1519 assert!(s.contains("\"transport\":\"udp\""), "{s}");
1520 assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1521 assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1523 assert!(!s.contains("password"), "no password field: {s}");
1525 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1527 }
1528
1529 #[test]
1530 fn account_tombstone_serializes_deleted_at() {
1531 let mut r = sample_account();
1534 r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1535 let s = serde_json::to_string(&r).unwrap();
1536 assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1537 }
1538
1539 #[test]
1540 fn account_record_round_trips_optional_fields() {
1541 let raw = r#"{
1544 "sourceId": "a",
1545 "enabled": false,
1546 "displayName": "Cheap trunk",
1547 "username": "u",
1548 "domain": "d",
1549 "transport": "tcp",
1550 "registerExpires": 120,
1551 "disclosureEnabled": false,
1552 "updatedAt": "2026-06-20T10:00:00Z"
1553 }"#;
1554 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1555 assert!(!parsed.enabled);
1556 assert!(parsed.auth_username.is_none());
1557 assert!(parsed.server.is_none());
1558 assert!(parsed.port.is_none());
1559 assert!(parsed.keepalive_secs.is_none());
1560 assert!(parsed.deleted_at.is_none());
1561 assert_eq!(parsed.transport, VoiceTransport::Tcp);
1562 assert_eq!(parsed.register_expires, 120);
1563 }
1564
1565 #[test]
1566 fn voice_transport_round_trips_via_json() {
1567 for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1568 let s = serde_json::to_string(&t).unwrap();
1569 let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1570 assert_eq!(t, back);
1571 }
1572 assert_eq!(
1575 serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1576 "\"udp\""
1577 );
1578 assert_eq!(
1579 serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1580 "\"tcp\""
1581 );
1582 }
1583
1584 #[test]
1585 fn accounts_query_omits_unset_and_serializes_include_deleted() {
1586 let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1587 assert_eq!(empty, "{}", "default query should be empty: {empty}");
1588 let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1589 include_deleted: Some(true),
1590 })
1591 .unwrap();
1592 assert!(
1593 with_deleted.contains("\"includeDeleted\":true"),
1594 "{with_deleted}"
1595 );
1596 }
1597
1598 #[test]
1599 fn account_record_accepts_unknown_extras_for_forward_compat() {
1600 let raw = r#"{
1603 "sourceId": "a",
1604 "enabled": true,
1605 "displayName": "x",
1606 "username": "u",
1607 "domain": "d",
1608 "transport": "udp",
1609 "registerExpires": 60,
1610 "disclosureEnabled": true,
1611 "updatedAt": "2026-06-20T10:00:00Z",
1612 "schemaVersion": 2,
1613 "extras": { "ringtone": "classic" }
1614 }"#;
1615 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1616 assert_eq!(parsed.envelope.schema_version, Some(2));
1617 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1618 assert_eq!(extras["ringtone"], "classic");
1619 }
1620}