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(flatten, default)]
113 pub envelope: SyncEnvelope,
114}
115
116#[derive(Debug, Clone, Default, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct VoiceCallsQuery {
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub before: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub limit: Option<u32>,
127}
128
129pub struct VoiceCalls;
133
134impl SyncEndpoint for VoiceCalls {
135 const RESOURCE: &'static str = "calls";
136 type Record = VoiceCallRecord;
137 type Query = VoiceCallsQuery;
138}
139
140impl HasSyncEnvelope for VoiceCallRecord {
141 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
142 &mut self.envelope
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct VoiceRecordingRecord {
163 pub source_id: String,
166 pub call_source_id: String,
170 pub size_bytes: u64,
174 pub duration_ms: u64,
175 pub sample_rate: u32,
176 pub channels: u16,
177 pub created_at: String,
181 #[serde(flatten, default)]
182 pub envelope: SyncEnvelope,
183}
184
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct VoiceRecordingsQuery {
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub before: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub limit: Option<u32>,
194}
195
196pub struct VoiceRecordings;
204
205impl SyncEndpoint for VoiceRecordings {
206 const RESOURCE: &'static str = "recordings";
207 type Record = VoiceRecordingRecord;
208 type Query = VoiceRecordingsQuery;
209}
210
211impl HasSyncEnvelope for VoiceRecordingRecord {
212 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
213 &mut self.envelope
214 }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "camelCase")]
225pub struct VoiceRecordingSyncItem {
226 pub source_id: String,
227 pub r2_key: String,
228 pub bytes_uploaded: bool,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct VoiceRecordingsSyncResponse {
236 pub accepted: u32,
237 pub skipped: u32,
238 pub items: Vec<VoiceRecordingSyncItem>,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum VoiceTranscriptChannel {
248 Local,
250 Remote,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct VoiceTranscriptRecord {
261 pub source_id: String,
265 pub call_source_id: String,
267 pub channel: VoiceTranscriptChannel,
268 pub ts_ms: i64,
271 pub end_ms: i64,
273 pub text: String,
275 #[serde(flatten, default)]
276 pub envelope: SyncEnvelope,
277}
278
279#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282#[serde(rename_all = "camelCase")]
283pub struct VoiceTranscriptsQuery {
284 pub call_source_id: String,
285}
286
287pub struct VoiceTranscripts;
289
290impl SyncEndpoint for VoiceTranscripts {
291 const RESOURCE: &'static str = "transcripts";
292 type Record = VoiceTranscriptRecord;
293 type Query = VoiceTranscriptsQuery;
294}
295
296impl HasSyncEnvelope for VoiceTranscriptRecord {
297 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
298 &mut self.envelope
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum VoiceTransport {
309 Udp,
310 Tcp,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct VoiceAccountRecord {
336 pub source_id: String,
341 pub enabled: bool,
344 pub display_name: String,
345 pub username: String,
346 pub domain: String,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub auth_username: Option<String>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub server: Option<String>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub port: Option<u16>,
353 pub transport: VoiceTransport,
354 pub register_expires: u32,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub keepalive_secs: Option<u32>,
357 pub disclosure_enabled: bool,
360 pub updated_at: String,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub deleted_at: Option<String>,
372 #[serde(flatten, default)]
374 pub envelope: SyncEnvelope,
375}
376
377#[derive(Debug, Clone, Default, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct VoiceAccountsQuery {
381 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub include_deleted: Option<bool>,
387}
388
389pub struct VoiceAccounts;
398
399impl SyncEndpoint for VoiceAccounts {
400 const RESOURCE: &'static str = "accounts";
401 type Record = VoiceAccountRecord;
402 type Query = VoiceAccountsQuery;
403}
404
405impl HasSyncEnvelope for VoiceAccountRecord {
406 fn envelope_mut(&mut self) -> &mut SyncEnvelope {
407 &mut self.envelope
408 }
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct SystemInfo {
432 pub os: String,
434 pub os_version: Option<String>,
437 pub arch: String,
439 pub locale: Option<String>,
442}
443
444impl SystemInfo {
445 pub fn detect() -> Self {
448 let os_version = match os_info::get().version() {
449 os_info::Version::Unknown => None,
450 v => Some(v.to_string()),
451 };
452 SystemInfo {
453 os: std::env::consts::OS.to_string(),
454 os_version,
455 arch: std::env::consts::ARCH.to_string(),
456 locale: sys_locale::get_locale(),
457 }
458 }
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
465#[serde(rename_all = "camelCase")]
466pub struct InstallHeartbeatRequest {
467 pub install_id: String,
469 pub app_version: String,
472 pub os: String,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub os_version: Option<String>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub arch: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub locale: Option<String>,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase")]
484pub struct InstallHeartbeatResponse {
485 pub id: String,
486 pub install_id: String,
487 pub app_version: String,
488 pub os: String,
489 pub os_version: Option<String>,
490 pub arch: Option<String>,
491 pub locale: Option<String>,
492 pub first_seen_at: String,
493 pub last_seen_at: String,
494}
495
496impl Client {
497 pub async fn install_heartbeat(
514 base_url: &str,
515 install_id: &str,
516 app_version: &str,
517 cred: &ReleaseCredential,
518 ) -> Result<InstallHeartbeatResponse> {
519 let sys = SystemInfo::detect();
520 let body = InstallHeartbeatRequest {
521 install_id: install_id.to_string(),
522 app_version: app_version.to_string(),
523 os: sys.os,
524 os_version: sys.os_version,
525 arch: Some(sys.arch),
526 locale: sys.locale,
527 };
528 Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
529 base_url,
530 "/api/voice/installs/heartbeat",
531 &body,
532 cred,
533 )
534 .await
535 }
536}
537
538impl Client {
553 pub async fn sync_recordings(
561 &self,
562 items: &[VoiceRecordingRecord],
563 ) -> Result<VoiceRecordingsSyncResponse> {
564 let stamped = stamp_schema_version::<VoiceRecordings>(items);
565 let body = SyncRequest { items: stamped };
566 self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
567 .await
568 }
569
570 pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
581 if source_id.is_empty() {
582 return Err(Error::BadRequest("source_id must not be empty".into()));
583 }
584 let path = format!("/api/voice/recordings/{source_id}/bytes");
585 self.put_raw_bytes(&path, "audio/wav", bytes).await
586 }
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
609#[serde(rename_all = "snake_case")]
610pub enum ShareVisibility {
611 Private,
612 Restricted,
613 Public,
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize)]
620#[serde(rename_all = "camelCase")]
621pub struct ShareRecordingRequest {
622 pub recording_source_id: String,
625 pub visibility: ShareVisibility,
626 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub invited_emails: Option<Vec<String>>,
630 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub password: Option<String>,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
635 pub expires_at: Option<String>,
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize)]
643#[serde(rename_all = "camelCase")]
644pub struct ShareRecordingResponse {
645 pub visibility: ShareVisibility,
646 pub token: String,
647 pub share_url: String,
648 pub shared_at: String,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
662#[serde(rename_all = "camelCase")]
663pub struct ShareStateResponse {
664 pub visibility: ShareVisibility,
665 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub token: Option<String>,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub share_url: Option<String>,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub shared_at: Option<String>,
673 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub invited_emails: Option<Vec<String>>,
677}
678
679impl Client {
680 pub async fn share_recording(
688 &self,
689 req: &ShareRecordingRequest,
690 ) -> Result<ShareRecordingResponse> {
691 if req.recording_source_id.is_empty() {
692 return Err(Error::BadRequest(
693 "recording_source_id must not be empty".into(),
694 ));
695 }
696 let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
697 self.post_json::<ShareRecordingResponse, _>(&path, req)
698 .await
699 }
700
701 pub async fn get_recording_share(
707 &self,
708 recording_source_id: &str,
709 ) -> Result<ShareStateResponse> {
710 if recording_source_id.is_empty() {
711 return Err(Error::BadRequest(
712 "recording_source_id must not be empty".into(),
713 ));
714 }
715 let path = format!("/api/voice/recordings/{recording_source_id}/share");
716 self.get_json::<ShareStateResponse>(&path).await
717 }
718
719 pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
722 if recording_source_id.is_empty() {
723 return Err(Error::BadRequest(
724 "recording_source_id must not be empty".into(),
725 ));
726 }
727 let path = format!("/api/voice/recordings/{recording_source_id}/share");
728 self.delete(&path).await
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735
736 #[test]
737 fn record_serializes_with_camel_case_keys() {
738 let r = VoiceCallRecord {
739 source_id: "11111111-1111-4111-8111-111111111111".into(),
740 account_id: "22222222-2222-4222-8222-222222222222".into(),
741 direction: VoiceCallDirection::Inbound,
742 party: "+14155550123".into(),
743 ring_at: "2026-05-16T10:00:00Z".into(),
744 answer_at: Some("2026-05-16T10:00:05Z".into()),
745 end_at: "2026-05-16T10:01:00Z".into(),
746 duration_ms: Some(55_000),
747 disposition: VoiceCallDisposition::Answered,
748 end_reason: VoiceCallEndReason::HangupRemote,
749 error: None,
750 envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
751 };
752 let s = serde_json::to_string(&r).unwrap();
753 assert!(s.contains("\"sourceId\":"), "{s}");
754 assert!(s.contains("\"accountId\":"), "{s}");
755 assert!(s.contains("\"ringAt\":"), "{s}");
756 assert!(s.contains("\"endAt\":"), "{s}");
757 assert!(s.contains("\"durationMs\":55000"), "{s}");
758 assert!(!s.contains("\"error\""), "error should be omitted: {s}");
760 assert!(
764 s.contains("\"schemaVersion\":1"),
765 "schemaVersion should flatten: {s}"
766 );
767 assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
770 }
771
772 #[test]
773 fn record_round_trips_optional_fields() {
774 let raw = r#"{
776 "sourceId": "a",
777 "accountId": "b",
778 "direction": "inbound",
779 "party": "anonymous",
780 "ringAt": "2026-05-16T10:00:00Z",
781 "endAt": "2026-05-16T10:00:30Z",
782 "disposition": "missed",
783 "endReason": "missed"
784 }"#;
785 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
786 assert!(parsed.answer_at.is_none());
787 assert!(parsed.duration_ms.is_none());
788 assert!(parsed.error.is_none());
789 assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
790 assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
791 }
792
793 #[test]
794 fn query_omits_unset_fields() {
795 let q = VoiceCallsQuery::default();
796 let s = serde_json::to_string(&q).unwrap();
797 assert_eq!(
799 s, "{}",
800 "default query should serialize to empty object: {s}"
801 );
802 }
803
804 #[test]
805 fn enum_round_trip_via_json() {
806 for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
810 let s = serde_json::to_string(&d).unwrap();
811 let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
812 assert_eq!(d, back);
813 }
814 for d in [
815 VoiceCallDisposition::Answered,
816 VoiceCallDisposition::Missed,
817 VoiceCallDisposition::Rejected,
818 VoiceCallDisposition::Cancelled,
819 VoiceCallDisposition::Failed,
820 ] {
821 let s = serde_json::to_string(&d).unwrap();
822 let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
823 assert_eq!(d, back);
824 }
825 for r in [
826 VoiceCallEndReason::HangupLocal,
827 VoiceCallEndReason::HangupRemote,
828 VoiceCallEndReason::RejectedLocal,
829 VoiceCallEndReason::RejectedRemote,
830 VoiceCallEndReason::Missed,
831 VoiceCallEndReason::CancelledLocal,
832 VoiceCallEndReason::ConnectionLost,
833 VoiceCallEndReason::Failed,
834 ] {
835 let s = serde_json::to_string(&r).unwrap();
836 let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
837 assert_eq!(r, back);
838 }
839 }
840
841 #[test]
842 fn connection_lost_pins_its_wire_string() {
843 let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
847 assert_eq!(s, "\"connection_lost\"");
848 }
849
850 #[test]
851 fn voice_calls_marker_resource_is_calls() {
852 assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
853 }
854
855 #[test]
856 fn record_accepts_unknown_extras_for_forward_compat() {
857 let raw = r#"{
863 "sourceId": "a",
864 "accountId": "b",
865 "direction": "inbound",
866 "party": "anon",
867 "ringAt": "2026-05-16T10:00:00Z",
868 "endAt": "2026-05-16T10:00:30Z",
869 "disposition": "answered",
870 "endReason": "hangup_remote",
871 "schemaVersion": 2,
872 "extras": { "notes": "from staging build" }
873 }"#;
874 let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
875 assert_eq!(parsed.envelope.schema_version, Some(2));
876 let extras = parsed.envelope.extras.as_ref().expect("extras present");
877 assert_eq!(extras["notes"], "from staging build");
878 }
879
880 #[test]
881 fn recording_marker_resource_is_recordings() {
882 assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
885 }
886
887 #[test]
888 fn recording_record_serializes_with_camel_case_and_envelope() {
889 let r = VoiceRecordingRecord {
890 source_id: "11111111-1111-4111-8111-111111111111".into(),
891 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
892 size_bytes: 44 + 64_000,
893 duration_ms: 2_000,
894 sample_rate: 8_000,
895 channels: 2,
896 created_at: "2026-05-16T10:01:05Z".into(),
897 envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
898 };
899 let s = serde_json::to_string(&r).unwrap();
900 assert!(s.contains("\"sourceId\":"), "{s}");
903 assert!(s.contains("\"callSourceId\":"), "{s}");
904 assert!(s.contains("\"sizeBytes\":64044"), "{s}");
905 assert!(s.contains("\"durationMs\":2000"), "{s}");
906 assert!(s.contains("\"sampleRate\":8000"), "{s}");
907 assert!(s.contains("\"channels\":2"), "{s}");
908 assert!(s.contains("\"createdAt\":"), "{s}");
909 assert!(s.contains("\"schemaVersion\":1"), "{s}");
911 }
912
913 #[test]
914 fn recordings_sync_response_round_trips() {
915 let raw = r#"{
920 "accepted": 2,
921 "skipped": 0,
922 "items": [
923 {"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
924 {"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
925 ]
926 }"#;
927 let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
928 assert_eq!(parsed.accepted, 2);
929 assert_eq!(parsed.items.len(), 2);
930 assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
931 assert!(!parsed.items[0].bytes_uploaded);
932 assert!(parsed.items[1].bytes_uploaded);
933 }
934
935 #[test]
936 fn install_heartbeat_request_serializes_with_camel_case_keys() {
937 let req = InstallHeartbeatRequest {
938 install_id: "11111111-1111-4111-8111-111111111111".into(),
939 app_version: "0.0.21".into(),
940 os: "macos".into(),
941 os_version: Some("15.5.0".into()),
942 arch: Some("aarch64".into()),
943 locale: Some("en-NZ".into()),
944 };
945 let s = serde_json::to_string(&req).unwrap();
946 assert!(s.contains("\"installId\":"), "{s}");
947 assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
948 assert!(s.contains("\"os\":\"macos\""), "{s}");
949 assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
950 assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
951 assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
952 }
953
954 #[test]
955 fn install_heartbeat_request_omits_absent_optional_fields() {
956 let req = InstallHeartbeatRequest {
961 install_id: "x".into(),
962 app_version: "0.0.21".into(),
963 os: "linux".into(),
964 os_version: None,
965 arch: None,
966 locale: None,
967 };
968 let s = serde_json::to_string(&req).unwrap();
969 assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
970 assert!(!s.contains("arch"), "arch should be omitted: {s}");
971 assert!(!s.contains("locale"), "locale should be omitted: {s}");
972 }
973
974 #[test]
975 fn install_heartbeat_response_parses_platform_shape() {
976 let raw = r#"{
977 "id": "abc-123",
978 "installId": "11111111-1111-4111-8111-111111111111",
979 "appVersion": "0.0.21",
980 "os": "macos",
981 "osVersion": "15.5.0",
982 "arch": "aarch64",
983 "locale": null,
984 "firstSeenAt": "2026-05-31T10:00:00.000Z",
985 "lastSeenAt": "2026-05-31T10:00:00.000Z"
986 }"#;
987 let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
988 assert_eq!(parsed.id, "abc-123");
989 assert_eq!(parsed.app_version, "0.0.21");
990 assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
991 assert!(parsed.locale.is_none());
992 }
993
994 #[test]
995 fn system_info_detect_fills_os_and_arch() {
996 let sys = SystemInfo::detect();
1000 assert!(!sys.os.is_empty(), "os should be a non-empty target string");
1001 assert!(
1002 !sys.arch.is_empty(),
1003 "arch should be a non-empty target string"
1004 );
1005 }
1006
1007 #[test]
1008 fn transcripts_marker_resource_is_transcripts() {
1009 assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
1010 }
1011
1012 #[test]
1013 fn transcript_record_serializes_with_camel_case_and_channel_enum() {
1014 let r = VoiceTranscriptRecord {
1015 source_id: "1".into(),
1016 call_source_id: "22222222-2222-4222-8222-222222222222".into(),
1017 channel: VoiceTranscriptChannel::Remote,
1018 ts_ms: 100,
1019 end_ms: 1_500,
1020 text: "hello".into(),
1021 envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
1022 };
1023 let s = serde_json::to_string(&r).unwrap();
1024 assert!(s.contains("\"sourceId\":"), "{s}");
1025 assert!(s.contains("\"callSourceId\":"), "{s}");
1026 assert!(s.contains("\"channel\":\"remote\""), "{s}");
1029 assert!(s.contains("\"tsMs\":100"), "{s}");
1030 assert!(s.contains("\"endMs\":1500"), "{s}");
1031 assert!(s.contains("\"text\":\"hello\""), "{s}");
1032 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1033 }
1034
1035 #[test]
1036 fn share_visibility_pins_its_wire_strings() {
1037 assert_eq!(
1040 serde_json::to_string(&ShareVisibility::Private).unwrap(),
1041 "\"private\""
1042 );
1043 assert_eq!(
1044 serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
1045 "\"restricted\""
1046 );
1047 assert_eq!(
1048 serde_json::to_string(&ShareVisibility::Public).unwrap(),
1049 "\"public\""
1050 );
1051 for v in [
1052 ShareVisibility::Private,
1053 ShareVisibility::Restricted,
1054 ShareVisibility::Public,
1055 ] {
1056 let s = serde_json::to_string(&v).unwrap();
1057 let back: ShareVisibility = serde_json::from_str(&s).unwrap();
1058 assert_eq!(v, back);
1059 }
1060 }
1061
1062 #[test]
1063 fn share_request_serializes_with_camel_case_and_omits_unset() {
1064 let req = ShareRecordingRequest {
1065 recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
1066 visibility: ShareVisibility::Public,
1067 invited_emails: None,
1068 password: None,
1069 expires_at: None,
1070 };
1071 let s = serde_json::to_string(&req).unwrap();
1072 assert!(s.contains("\"recordingSourceId\":"), "{s}");
1073 assert!(s.contains("\"visibility\":\"public\""), "{s}");
1074 assert!(!s.contains("invitedEmails"), "{s}");
1077 assert!(!s.contains("password"), "{s}");
1078 assert!(!s.contains("expiresAt"), "{s}");
1079 }
1080
1081 #[test]
1082 fn share_request_carries_invited_emails_for_restricted() {
1083 let req = ShareRecordingRequest {
1084 recording_source_id: "a".into(),
1085 visibility: ShareVisibility::Restricted,
1086 invited_emails: Some(vec!["alex@example.com".into()]),
1087 password: None,
1088 expires_at: None,
1089 };
1090 let s = serde_json::to_string(&req).unwrap();
1091 assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
1092 assert!(
1093 s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
1094 "{s}"
1095 );
1096 }
1097
1098 #[test]
1099 fn share_response_parses_platform_shape() {
1100 let raw = r#"{
1101 "visibility": "public",
1102 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1103 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1104 "sharedAt": "2026-06-19T10:00:00.000Z"
1105 }"#;
1106 let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
1107 assert_eq!(parsed.visibility, ShareVisibility::Public);
1108 assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
1109 assert!(parsed.share_url.ends_with(&parsed.token));
1110 }
1111
1112 #[test]
1113 fn share_state_parses_restricted_with_invited_emails() {
1114 let raw = r#"{
1117 "visibility": "restricted",
1118 "token": "Zr7-x9F2k1QpLmN4sT8wYa",
1119 "shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
1120 "sharedAt": "2026-06-19T10:00:00.000Z",
1121 "invitedEmails": ["bob@example.com", "carol@example.com"]
1122 }"#;
1123 let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
1124 assert_eq!(parsed.visibility, ShareVisibility::Restricted);
1125 assert_eq!(
1126 parsed.invited_emails.as_deref(),
1127 Some(
1128 [
1129 "bob@example.com".to_string(),
1130 "carol@example.com".to_string()
1131 ]
1132 .as_slice()
1133 )
1134 );
1135 }
1136
1137 #[test]
1138 fn share_state_parses_private_with_fields_absent() {
1139 let parsed: ShareStateResponse =
1142 serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
1143 assert_eq!(parsed.visibility, ShareVisibility::Private);
1144 assert!(parsed.token.is_none());
1145 assert!(parsed.share_url.is_none());
1146 assert!(parsed.shared_at.is_none());
1147 assert!(parsed.invited_emails.is_none());
1148 }
1149
1150 #[test]
1151 fn share_request_rejects_empty_source_id_before_hitting_network() {
1152 let req = ShareRecordingRequest {
1155 recording_source_id: String::new(),
1156 visibility: ShareVisibility::Private,
1157 invited_emails: None,
1158 password: None,
1159 expires_at: None,
1160 };
1161 assert!(req.recording_source_id.is_empty());
1165 }
1166
1167 fn sample_account() -> VoiceAccountRecord {
1170 VoiceAccountRecord {
1171 source_id: "11111111-1111-4111-8111-111111111111".into(),
1172 enabled: true,
1173 display_name: "Work line".into(),
1174 username: "alice".into(),
1175 domain: "sip.example.com".into(),
1176 auth_username: Some("alice-auth".into()),
1177 server: Some("sip.example.com".into()),
1178 port: Some(5060),
1179 transport: VoiceTransport::Udp,
1180 register_expires: 60,
1181 keepalive_secs: Some(50),
1182 disclosure_enabled: true,
1183 updated_at: "2026-06-20T10:00:00Z".into(),
1184 deleted_at: None,
1185 envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
1186 }
1187 }
1188
1189 #[test]
1190 fn accounts_marker_resource_is_accounts() {
1191 assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
1194 }
1195
1196 #[test]
1197 fn account_record_serializes_with_camel_case_and_envelope() {
1198 let s = serde_json::to_string(&sample_account()).unwrap();
1199 assert!(s.contains("\"sourceId\":"), "{s}");
1202 assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
1203 assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
1204 assert!(s.contains("\"registerExpires\":60"), "{s}");
1205 assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
1206 assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
1207 assert!(s.contains("\"transport\":\"udp\""), "{s}");
1208 assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
1209 assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
1211 assert!(!s.contains("password"), "no password field: {s}");
1213 assert!(s.contains("\"schemaVersion\":1"), "{s}");
1215 }
1216
1217 #[test]
1218 fn account_tombstone_serializes_deleted_at() {
1219 let mut r = sample_account();
1222 r.deleted_at = Some("2026-06-20T12:00:00Z".into());
1223 let s = serde_json::to_string(&r).unwrap();
1224 assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
1225 }
1226
1227 #[test]
1228 fn account_record_round_trips_optional_fields() {
1229 let raw = r#"{
1232 "sourceId": "a",
1233 "enabled": false,
1234 "displayName": "Cheap trunk",
1235 "username": "u",
1236 "domain": "d",
1237 "transport": "tcp",
1238 "registerExpires": 120,
1239 "disclosureEnabled": false,
1240 "updatedAt": "2026-06-20T10:00:00Z"
1241 }"#;
1242 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1243 assert!(!parsed.enabled);
1244 assert!(parsed.auth_username.is_none());
1245 assert!(parsed.server.is_none());
1246 assert!(parsed.port.is_none());
1247 assert!(parsed.keepalive_secs.is_none());
1248 assert!(parsed.deleted_at.is_none());
1249 assert_eq!(parsed.transport, VoiceTransport::Tcp);
1250 assert_eq!(parsed.register_expires, 120);
1251 }
1252
1253 #[test]
1254 fn voice_transport_round_trips_via_json() {
1255 for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
1256 let s = serde_json::to_string(&t).unwrap();
1257 let back: VoiceTransport = serde_json::from_str(&s).unwrap();
1258 assert_eq!(t, back);
1259 }
1260 assert_eq!(
1263 serde_json::to_string(&VoiceTransport::Udp).unwrap(),
1264 "\"udp\""
1265 );
1266 assert_eq!(
1267 serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
1268 "\"tcp\""
1269 );
1270 }
1271
1272 #[test]
1273 fn accounts_query_omits_unset_and_serializes_include_deleted() {
1274 let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
1275 assert_eq!(empty, "{}", "default query should be empty: {empty}");
1276 let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
1277 include_deleted: Some(true),
1278 })
1279 .unwrap();
1280 assert!(
1281 with_deleted.contains("\"includeDeleted\":true"),
1282 "{with_deleted}"
1283 );
1284 }
1285
1286 #[test]
1287 fn account_record_accepts_unknown_extras_for_forward_compat() {
1288 let raw = r#"{
1291 "sourceId": "a",
1292 "enabled": true,
1293 "displayName": "x",
1294 "username": "u",
1295 "domain": "d",
1296 "transport": "udp",
1297 "registerExpires": 60,
1298 "disclosureEnabled": true,
1299 "updatedAt": "2026-06-20T10:00:00Z",
1300 "schemaVersion": 2,
1301 "extras": { "ringtone": "classic" }
1302 }"#;
1303 let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
1304 assert_eq!(parsed.envelope.schema_version, Some(2));
1305 let extras = parsed.envelope.extras.as_ref().expect("extras present");
1306 assert_eq!(extras["ringtone"], "classic");
1307 }
1308}