use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::error::{Error, Result};
use crate::sign::ReleaseCredential;
use crate::sync::{stamp_schema_version, HasSyncEnvelope, SyncEndpoint, SyncEnvelope, SyncRequest};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceCallDirection {
Inbound,
Outbound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceCallDisposition {
Answered,
Missed,
Rejected,
Cancelled,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceCallEndReason {
HangupLocal,
HangupRemote,
RejectedLocal,
RejectedRemote,
Missed,
CancelledLocal,
ConnectionLost,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceCallRecord {
pub source_id: String,
pub account_id: String,
pub direction: VoiceCallDirection,
pub party: String,
pub ring_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answer_at: Option<String>,
pub end_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
pub disposition: VoiceCallDisposition,
pub end_reason: VoiceCallEndReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(flatten, default)]
pub envelope: SyncEnvelope,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceCallsQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
pub struct VoiceCalls;
impl SyncEndpoint for VoiceCalls {
const RESOURCE: &'static str = "calls";
type Record = VoiceCallRecord;
type Query = VoiceCallsQuery;
}
impl HasSyncEnvelope for VoiceCallRecord {
fn envelope_mut(&mut self) -> &mut SyncEnvelope {
&mut self.envelope
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceRecordingRecord {
pub source_id: String,
pub call_source_id: String,
pub size_bytes: u64,
pub duration_ms: u64,
pub sample_rate: u32,
pub channels: u16,
pub created_at: String,
#[serde(flatten, default)]
pub envelope: SyncEnvelope,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceRecordingsQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
pub struct VoiceRecordings;
impl SyncEndpoint for VoiceRecordings {
const RESOURCE: &'static str = "recordings";
type Record = VoiceRecordingRecord;
type Query = VoiceRecordingsQuery;
}
impl HasSyncEnvelope for VoiceRecordingRecord {
fn envelope_mut(&mut self) -> &mut SyncEnvelope {
&mut self.envelope
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceRecordingSyncItem {
pub source_id: String,
pub r2_key: String,
pub bytes_uploaded: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceRecordingsSyncResponse {
pub accepted: u32,
pub skipped: u32,
pub items: Vec<VoiceRecordingSyncItem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceTranscriptChannel {
Local,
Remote,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceTranscriptRecord {
pub source_id: String,
pub call_source_id: String,
pub channel: VoiceTranscriptChannel,
pub ts_ms: i64,
pub end_ms: i64,
pub text: String,
#[serde(flatten, default)]
pub envelope: SyncEnvelope,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceTranscriptsQuery {
pub call_source_id: String,
}
pub struct VoiceTranscripts;
impl SyncEndpoint for VoiceTranscripts {
const RESOURCE: &'static str = "transcripts";
type Record = VoiceTranscriptRecord;
type Query = VoiceTranscriptsQuery;
}
impl HasSyncEnvelope for VoiceTranscriptRecord {
fn envelope_mut(&mut self) -> &mut SyncEnvelope {
&mut self.envelope
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceTransport {
Udp,
Tcp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceAccountRecord {
pub source_id: String,
pub enabled: bool,
pub display_name: String,
pub username: String,
pub domain: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_username: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
pub transport: VoiceTransport,
pub register_expires: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keepalive_secs: Option<u32>,
pub disclosure_enabled: bool,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<String>,
#[serde(flatten, default)]
pub envelope: SyncEnvelope,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceAccountsQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_deleted: Option<bool>,
}
pub struct VoiceAccounts;
impl SyncEndpoint for VoiceAccounts {
const RESOURCE: &'static str = "accounts";
type Record = VoiceAccountRecord;
type Query = VoiceAccountsQuery;
}
impl HasSyncEnvelope for VoiceAccountRecord {
fn envelope_mut(&mut self) -> &mut SyncEnvelope {
&mut self.envelope
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemInfo {
pub os: String,
pub os_version: Option<String>,
pub arch: String,
pub locale: Option<String>,
}
impl SystemInfo {
pub fn detect() -> Self {
let os_version = match os_info::get().version() {
os_info::Version::Unknown => None,
v => Some(v.to_string()),
};
SystemInfo {
os: std::env::consts::OS.to_string(),
os_version,
arch: std::env::consts::ARCH.to_string(),
locale: sys_locale::get_locale(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallHeartbeatRequest {
pub install_id: String,
pub app_version: String,
pub os: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallHeartbeatResponse {
pub id: String,
pub install_id: String,
pub app_version: String,
pub os: String,
pub os_version: Option<String>,
pub arch: Option<String>,
pub locale: Option<String>,
pub first_seen_at: String,
pub last_seen_at: String,
}
impl Client {
pub async fn install_heartbeat(
base_url: &str,
install_id: &str,
app_version: &str,
cred: &ReleaseCredential,
) -> Result<InstallHeartbeatResponse> {
let sys = SystemInfo::detect();
let body = InstallHeartbeatRequest {
install_id: install_id.to_string(),
app_version: app_version.to_string(),
os: sys.os,
os_version: sys.os_version,
arch: Some(sys.arch),
locale: sys.locale,
};
Client::post_public_signed_json::<InstallHeartbeatResponse, _>(
base_url,
"/api/voice/installs/heartbeat",
&body,
cred,
)
.await
}
}
impl Client {
pub async fn sync_recordings(
&self,
items: &[VoiceRecordingRecord],
) -> Result<VoiceRecordingsSyncResponse> {
let stamped = stamp_schema_version::<VoiceRecordings>(items);
let body = SyncRequest { items: stamped };
self.post_json::<VoiceRecordingsSyncResponse, _>("/api/voice/recordings/sync", &body)
.await
}
pub async fn upload_recording_bytes(&self, source_id: &str, bytes: Vec<u8>) -> Result<()> {
if source_id.is_empty() {
return Err(Error::BadRequest("source_id must not be empty".into()));
}
let path = format!("/api/voice/recordings/{source_id}/bytes");
self.put_raw_bytes(&path, "audio/wav", bytes).await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShareVisibility {
Private,
Restricted,
Public,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShareRecordingRequest {
pub recording_source_id: String,
pub visibility: ShareVisibility,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invited_emails: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShareRecordingResponse {
pub visibility: ShareVisibility,
pub token: String,
pub share_url: String,
pub shared_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShareStateResponse {
pub visibility: ShareVisibility,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub share_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shared_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invited_emails: Option<Vec<String>>,
}
impl Client {
pub async fn share_recording(
&self,
req: &ShareRecordingRequest,
) -> Result<ShareRecordingResponse> {
if req.recording_source_id.is_empty() {
return Err(Error::BadRequest(
"recording_source_id must not be empty".into(),
));
}
let path = format!("/api/voice/recordings/{}/share", req.recording_source_id);
self.post_json::<ShareRecordingResponse, _>(&path, req)
.await
}
pub async fn get_recording_share(
&self,
recording_source_id: &str,
) -> Result<ShareStateResponse> {
if recording_source_id.is_empty() {
return Err(Error::BadRequest(
"recording_source_id must not be empty".into(),
));
}
let path = format!("/api/voice/recordings/{recording_source_id}/share");
self.get_json::<ShareStateResponse>(&path).await
}
pub async fn revoke_recording_share(&self, recording_source_id: &str) -> Result<()> {
if recording_source_id.is_empty() {
return Err(Error::BadRequest(
"recording_source_id must not be empty".into(),
));
}
let path = format!("/api/voice/recordings/{recording_source_id}/share");
self.delete(&path).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_serializes_with_camel_case_keys() {
let r = VoiceCallRecord {
source_id: "11111111-1111-4111-8111-111111111111".into(),
account_id: "22222222-2222-4222-8222-222222222222".into(),
direction: VoiceCallDirection::Inbound,
party: "+14155550123".into(),
ring_at: "2026-05-16T10:00:00Z".into(),
answer_at: Some("2026-05-16T10:00:05Z".into()),
end_at: "2026-05-16T10:01:00Z".into(),
duration_ms: Some(55_000),
disposition: VoiceCallDisposition::Answered,
end_reason: VoiceCallEndReason::HangupRemote,
error: None,
envelope: SyncEnvelope::for_endpoint::<VoiceCalls>(),
};
let s = serde_json::to_string(&r).unwrap();
assert!(s.contains("\"sourceId\":"), "{s}");
assert!(s.contains("\"accountId\":"), "{s}");
assert!(s.contains("\"ringAt\":"), "{s}");
assert!(s.contains("\"endAt\":"), "{s}");
assert!(s.contains("\"durationMs\":55000"), "{s}");
assert!(!s.contains("\"error\""), "error should be omitted: {s}");
assert!(
s.contains("\"schemaVersion\":1"),
"schemaVersion should flatten: {s}"
);
assert!(!s.contains("\"extras\""), "extras should be omitted: {s}");
}
#[test]
fn record_round_trips_optional_fields() {
let raw = r#"{
"sourceId": "a",
"accountId": "b",
"direction": "inbound",
"party": "anonymous",
"ringAt": "2026-05-16T10:00:00Z",
"endAt": "2026-05-16T10:00:30Z",
"disposition": "missed",
"endReason": "missed"
}"#;
let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
assert!(parsed.answer_at.is_none());
assert!(parsed.duration_ms.is_none());
assert!(parsed.error.is_none());
assert_eq!(parsed.disposition, VoiceCallDisposition::Missed);
assert_eq!(parsed.end_reason, VoiceCallEndReason::Missed);
}
#[test]
fn query_omits_unset_fields() {
let q = VoiceCallsQuery::default();
let s = serde_json::to_string(&q).unwrap();
assert_eq!(
s, "{}",
"default query should serialize to empty object: {s}"
);
}
#[test]
fn enum_round_trip_via_json() {
for d in [VoiceCallDirection::Inbound, VoiceCallDirection::Outbound] {
let s = serde_json::to_string(&d).unwrap();
let back: VoiceCallDirection = serde_json::from_str(&s).unwrap();
assert_eq!(d, back);
}
for d in [
VoiceCallDisposition::Answered,
VoiceCallDisposition::Missed,
VoiceCallDisposition::Rejected,
VoiceCallDisposition::Cancelled,
VoiceCallDisposition::Failed,
] {
let s = serde_json::to_string(&d).unwrap();
let back: VoiceCallDisposition = serde_json::from_str(&s).unwrap();
assert_eq!(d, back);
}
for r in [
VoiceCallEndReason::HangupLocal,
VoiceCallEndReason::HangupRemote,
VoiceCallEndReason::RejectedLocal,
VoiceCallEndReason::RejectedRemote,
VoiceCallEndReason::Missed,
VoiceCallEndReason::CancelledLocal,
VoiceCallEndReason::ConnectionLost,
VoiceCallEndReason::Failed,
] {
let s = serde_json::to_string(&r).unwrap();
let back: VoiceCallEndReason = serde_json::from_str(&s).unwrap();
assert_eq!(r, back);
}
}
#[test]
fn connection_lost_pins_its_wire_string() {
let s = serde_json::to_string(&VoiceCallEndReason::ConnectionLost).unwrap();
assert_eq!(s, "\"connection_lost\"");
}
#[test]
fn voice_calls_marker_resource_is_calls() {
assert_eq!(<VoiceCalls as SyncEndpoint>::RESOURCE, "calls");
}
#[test]
fn record_accepts_unknown_extras_for_forward_compat() {
let raw = r#"{
"sourceId": "a",
"accountId": "b",
"direction": "inbound",
"party": "anon",
"ringAt": "2026-05-16T10:00:00Z",
"endAt": "2026-05-16T10:00:30Z",
"disposition": "answered",
"endReason": "hangup_remote",
"schemaVersion": 2,
"extras": { "notes": "from staging build" }
}"#;
let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.envelope.schema_version, Some(2));
let extras = parsed.envelope.extras.as_ref().expect("extras present");
assert_eq!(extras["notes"], "from staging build");
}
#[test]
fn recording_marker_resource_is_recordings() {
assert_eq!(<VoiceRecordings as SyncEndpoint>::RESOURCE, "recordings");
}
#[test]
fn recording_record_serializes_with_camel_case_and_envelope() {
let r = VoiceRecordingRecord {
source_id: "11111111-1111-4111-8111-111111111111".into(),
call_source_id: "22222222-2222-4222-8222-222222222222".into(),
size_bytes: 44 + 64_000,
duration_ms: 2_000,
sample_rate: 8_000,
channels: 2,
created_at: "2026-05-16T10:01:05Z".into(),
envelope: SyncEnvelope::for_endpoint::<VoiceRecordings>(),
};
let s = serde_json::to_string(&r).unwrap();
assert!(s.contains("\"sourceId\":"), "{s}");
assert!(s.contains("\"callSourceId\":"), "{s}");
assert!(s.contains("\"sizeBytes\":64044"), "{s}");
assert!(s.contains("\"durationMs\":2000"), "{s}");
assert!(s.contains("\"sampleRate\":8000"), "{s}");
assert!(s.contains("\"channels\":2"), "{s}");
assert!(s.contains("\"createdAt\":"), "{s}");
assert!(s.contains("\"schemaVersion\":1"), "{s}");
}
#[test]
fn recordings_sync_response_round_trips() {
let raw = r#"{
"accepted": 2,
"skipped": 0,
"items": [
{"sourceId": "a", "r2Key": "voice/recordings/1/a.wav", "bytesUploaded": false},
{"sourceId": "b", "r2Key": "voice/recordings/1/b.wav", "bytesUploaded": true}
]
}"#;
let parsed: VoiceRecordingsSyncResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.accepted, 2);
assert_eq!(parsed.items.len(), 2);
assert_eq!(parsed.items[0].r2_key, "voice/recordings/1/a.wav");
assert!(!parsed.items[0].bytes_uploaded);
assert!(parsed.items[1].bytes_uploaded);
}
#[test]
fn install_heartbeat_request_serializes_with_camel_case_keys() {
let req = InstallHeartbeatRequest {
install_id: "11111111-1111-4111-8111-111111111111".into(),
app_version: "0.0.21".into(),
os: "macos".into(),
os_version: Some("15.5.0".into()),
arch: Some("aarch64".into()),
locale: Some("en-NZ".into()),
};
let s = serde_json::to_string(&req).unwrap();
assert!(s.contains("\"installId\":"), "{s}");
assert!(s.contains("\"appVersion\":\"0.0.21\""), "{s}");
assert!(s.contains("\"os\":\"macos\""), "{s}");
assert!(s.contains("\"osVersion\":\"15.5.0\""), "{s}");
assert!(s.contains("\"arch\":\"aarch64\""), "{s}");
assert!(s.contains("\"locale\":\"en-NZ\""), "{s}");
}
#[test]
fn install_heartbeat_request_omits_absent_optional_fields() {
let req = InstallHeartbeatRequest {
install_id: "x".into(),
app_version: "0.0.21".into(),
os: "linux".into(),
os_version: None,
arch: None,
locale: None,
};
let s = serde_json::to_string(&req).unwrap();
assert!(!s.contains("osVersion"), "osVersion should be omitted: {s}");
assert!(!s.contains("arch"), "arch should be omitted: {s}");
assert!(!s.contains("locale"), "locale should be omitted: {s}");
}
#[test]
fn install_heartbeat_response_parses_platform_shape() {
let raw = r#"{
"id": "abc-123",
"installId": "11111111-1111-4111-8111-111111111111",
"appVersion": "0.0.21",
"os": "macos",
"osVersion": "15.5.0",
"arch": "aarch64",
"locale": null,
"firstSeenAt": "2026-05-31T10:00:00.000Z",
"lastSeenAt": "2026-05-31T10:00:00.000Z"
}"#;
let parsed: InstallHeartbeatResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.id, "abc-123");
assert_eq!(parsed.app_version, "0.0.21");
assert_eq!(parsed.os_version.as_deref(), Some("15.5.0"));
assert!(parsed.locale.is_none());
}
#[test]
fn system_info_detect_fills_os_and_arch() {
let sys = SystemInfo::detect();
assert!(!sys.os.is_empty(), "os should be a non-empty target string");
assert!(
!sys.arch.is_empty(),
"arch should be a non-empty target string"
);
}
#[test]
fn transcripts_marker_resource_is_transcripts() {
assert_eq!(<VoiceTranscripts as SyncEndpoint>::RESOURCE, "transcripts");
}
#[test]
fn transcript_record_serializes_with_camel_case_and_channel_enum() {
let r = VoiceTranscriptRecord {
source_id: "1".into(),
call_source_id: "22222222-2222-4222-8222-222222222222".into(),
channel: VoiceTranscriptChannel::Remote,
ts_ms: 100,
end_ms: 1_500,
text: "hello".into(),
envelope: SyncEnvelope::for_endpoint::<VoiceTranscripts>(),
};
let s = serde_json::to_string(&r).unwrap();
assert!(s.contains("\"sourceId\":"), "{s}");
assert!(s.contains("\"callSourceId\":"), "{s}");
assert!(s.contains("\"channel\":\"remote\""), "{s}");
assert!(s.contains("\"tsMs\":100"), "{s}");
assert!(s.contains("\"endMs\":1500"), "{s}");
assert!(s.contains("\"text\":\"hello\""), "{s}");
assert!(s.contains("\"schemaVersion\":1"), "{s}");
}
#[test]
fn share_visibility_pins_its_wire_strings() {
assert_eq!(
serde_json::to_string(&ShareVisibility::Private).unwrap(),
"\"private\""
);
assert_eq!(
serde_json::to_string(&ShareVisibility::Restricted).unwrap(),
"\"restricted\""
);
assert_eq!(
serde_json::to_string(&ShareVisibility::Public).unwrap(),
"\"public\""
);
for v in [
ShareVisibility::Private,
ShareVisibility::Restricted,
ShareVisibility::Public,
] {
let s = serde_json::to_string(&v).unwrap();
let back: ShareVisibility = serde_json::from_str(&s).unwrap();
assert_eq!(v, back);
}
}
#[test]
fn share_request_serializes_with_camel_case_and_omits_unset() {
let req = ShareRecordingRequest {
recording_source_id: "11111111-1111-4111-8111-111111111111".into(),
visibility: ShareVisibility::Public,
invited_emails: None,
password: None,
expires_at: None,
};
let s = serde_json::to_string(&req).unwrap();
assert!(s.contains("\"recordingSourceId\":"), "{s}");
assert!(s.contains("\"visibility\":\"public\""), "{s}");
assert!(!s.contains("invitedEmails"), "{s}");
assert!(!s.contains("password"), "{s}");
assert!(!s.contains("expiresAt"), "{s}");
}
#[test]
fn share_request_carries_invited_emails_for_restricted() {
let req = ShareRecordingRequest {
recording_source_id: "a".into(),
visibility: ShareVisibility::Restricted,
invited_emails: Some(vec!["alex@example.com".into()]),
password: None,
expires_at: None,
};
let s = serde_json::to_string(&req).unwrap();
assert!(s.contains("\"visibility\":\"restricted\""), "{s}");
assert!(
s.contains("\"invitedEmails\":[\"alex@example.com\"]"),
"{s}"
);
}
#[test]
fn share_response_parses_platform_shape() {
let raw = r#"{
"visibility": "public",
"token": "Zr7-x9F2k1QpLmN4sT8wYa",
"shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
"sharedAt": "2026-06-19T10:00:00.000Z"
}"#;
let parsed: ShareRecordingResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.visibility, ShareVisibility::Public);
assert_eq!(parsed.token, "Zr7-x9F2k1QpLmN4sT8wYa");
assert!(parsed.share_url.ends_with(&parsed.token));
}
#[test]
fn share_state_parses_restricted_with_invited_emails() {
let raw = r#"{
"visibility": "restricted",
"token": "Zr7-x9F2k1QpLmN4sT8wYa",
"shareUrl": "https://platform.wavekat.com/voice/s/Zr7-x9F2k1QpLmN4sT8wYa",
"sharedAt": "2026-06-19T10:00:00.000Z",
"invitedEmails": ["bob@example.com", "carol@example.com"]
}"#;
let parsed: ShareStateResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.visibility, ShareVisibility::Restricted);
assert_eq!(
parsed.invited_emails.as_deref(),
Some(
[
"bob@example.com".to_string(),
"carol@example.com".to_string()
]
.as_slice()
)
);
}
#[test]
fn share_state_parses_private_with_fields_absent() {
let parsed: ShareStateResponse =
serde_json::from_str(r#"{ "visibility": "private" }"#).unwrap();
assert_eq!(parsed.visibility, ShareVisibility::Private);
assert!(parsed.token.is_none());
assert!(parsed.share_url.is_none());
assert!(parsed.shared_at.is_none());
assert!(parsed.invited_emails.is_none());
}
#[test]
fn share_request_rejects_empty_source_id_before_hitting_network() {
let req = ShareRecordingRequest {
recording_source_id: String::new(),
visibility: ShareVisibility::Private,
invited_emails: None,
password: None,
expires_at: None,
};
assert!(req.recording_source_id.is_empty());
}
fn sample_account() -> VoiceAccountRecord {
VoiceAccountRecord {
source_id: "11111111-1111-4111-8111-111111111111".into(),
enabled: true,
display_name: "Work line".into(),
username: "alice".into(),
domain: "sip.example.com".into(),
auth_username: Some("alice-auth".into()),
server: Some("sip.example.com".into()),
port: Some(5060),
transport: VoiceTransport::Udp,
register_expires: 60,
keepalive_secs: Some(50),
disclosure_enabled: true,
updated_at: "2026-06-20T10:00:00Z".into(),
deleted_at: None,
envelope: SyncEnvelope::for_endpoint::<VoiceAccounts>(),
}
}
#[test]
fn accounts_marker_resource_is_accounts() {
assert_eq!(<VoiceAccounts as SyncEndpoint>::RESOURCE, "accounts");
}
#[test]
fn account_record_serializes_with_camel_case_and_envelope() {
let s = serde_json::to_string(&sample_account()).unwrap();
assert!(s.contains("\"sourceId\":"), "{s}");
assert!(s.contains("\"displayName\":\"Work line\""), "{s}");
assert!(s.contains("\"authUsername\":\"alice-auth\""), "{s}");
assert!(s.contains("\"registerExpires\":60"), "{s}");
assert!(s.contains("\"keepaliveSecs\":50"), "{s}");
assert!(s.contains("\"disclosureEnabled\":true"), "{s}");
assert!(s.contains("\"transport\":\"udp\""), "{s}");
assert!(s.contains("\"updatedAt\":\"2026-06-20T10:00:00Z\""), "{s}");
assert!(!s.contains("deletedAt"), "deletedAt should be omitted: {s}");
assert!(!s.contains("password"), "no password field: {s}");
assert!(s.contains("\"schemaVersion\":1"), "{s}");
}
#[test]
fn account_tombstone_serializes_deleted_at() {
let mut r = sample_account();
r.deleted_at = Some("2026-06-20T12:00:00Z".into());
let s = serde_json::to_string(&r).unwrap();
assert!(s.contains("\"deletedAt\":\"2026-06-20T12:00:00Z\""), "{s}");
}
#[test]
fn account_record_round_trips_optional_fields() {
let raw = r#"{
"sourceId": "a",
"enabled": false,
"displayName": "Cheap trunk",
"username": "u",
"domain": "d",
"transport": "tcp",
"registerExpires": 120,
"disclosureEnabled": false,
"updatedAt": "2026-06-20T10:00:00Z"
}"#;
let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
assert!(!parsed.enabled);
assert!(parsed.auth_username.is_none());
assert!(parsed.server.is_none());
assert!(parsed.port.is_none());
assert!(parsed.keepalive_secs.is_none());
assert!(parsed.deleted_at.is_none());
assert_eq!(parsed.transport, VoiceTransport::Tcp);
assert_eq!(parsed.register_expires, 120);
}
#[test]
fn voice_transport_round_trips_via_json() {
for t in [VoiceTransport::Udp, VoiceTransport::Tcp] {
let s = serde_json::to_string(&t).unwrap();
let back: VoiceTransport = serde_json::from_str(&s).unwrap();
assert_eq!(t, back);
}
assert_eq!(
serde_json::to_string(&VoiceTransport::Udp).unwrap(),
"\"udp\""
);
assert_eq!(
serde_json::to_string(&VoiceTransport::Tcp).unwrap(),
"\"tcp\""
);
}
#[test]
fn accounts_query_omits_unset_and_serializes_include_deleted() {
let empty = serde_json::to_string(&VoiceAccountsQuery::default()).unwrap();
assert_eq!(empty, "{}", "default query should be empty: {empty}");
let with_deleted = serde_json::to_string(&VoiceAccountsQuery {
include_deleted: Some(true),
})
.unwrap();
assert!(
with_deleted.contains("\"includeDeleted\":true"),
"{with_deleted}"
);
}
#[test]
fn account_record_accepts_unknown_extras_for_forward_compat() {
let raw = r#"{
"sourceId": "a",
"enabled": true,
"displayName": "x",
"username": "u",
"domain": "d",
"transport": "udp",
"registerExpires": 60,
"disclosureEnabled": true,
"updatedAt": "2026-06-20T10:00:00Z",
"schemaVersion": 2,
"extras": { "ringtone": "classic" }
}"#;
let parsed: VoiceAccountRecord = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.envelope.schema_version, Some(2));
let extras = parsed.envelope.extras.as_ref().expect("extras present");
assert_eq!(extras["ringtone"], "classic");
}
}