use futures_util::StreamExt as _;
use serde_json::{json, Value};
use wiremock::matchers::{body_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::resources::sip::{
CreateInboundTrunkParams, CreateOutboundTrunkParams, CreatePhoneNumbersParams,
CreateRoutingRuleParams, CreateSipCallParams, CreateSipWebhookParams, PhoneNumberInboundConfig,
PhoneNumberOutboundConfig, RoutingRuleTarget, SipAuth, SipCallListParams, SipDirection,
SipRegion, SipRoomType, SipRoutingRuleListParams, SipTransport, SipTrunkListParams,
SipWebhookEvent, SipWebhookListParams, SwitchRoomParams, TransferSipCallParams,
UpdateInboundTrunkParams, UpdateRoutingRuleParams,
};
use crate::test_support::{body, client, only_request, requests};
async fn mount(server: &MockServer, verb: &str, route: &str, response: Value) {
Mock::given(method(verb))
.and(path(route))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(server)
.await;
}
#[tokio::test]
async fn creating_an_inbound_trunk_posts_to_the_inbound_gateways_route() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/sip/inbound-gateways"))
.and(body_json(json!({
"name": "main",
"numbers": ["+15550001"],
"auth": {"username": "u", "password": "p"},
"geoRegion": "in002",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "gw-1"})))
.expect(1)
.mount(&server)
.await;
let trunk = client(&server)
.sip()
.trunks()
.inbound()
.create(CreateInboundTrunkParams {
name: "main".into(),
numbers: vec!["+15550001".into()],
auth: Some(SipAuth {
username: "u".into(),
password: "p".into(),
}),
geo_region: Some(SipRegion::IN002),
..Default::default()
})
.await
.unwrap();
assert_eq!(trunk.id, "gw-1");
}
#[tokio::test]
async fn creating_an_outbound_trunk_posts_to_the_outbound_gateways_route() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/sip/outbound-gateways"))
.and(body_json(json!({
"name": "provider",
"address": "sip.telnyx.com:5061",
"numbers": ["+15550002"],
"transport": "tls",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "gw-2"})))
.expect(1)
.mount(&server)
.await;
client(&server)
.sip()
.trunks()
.outbound()
.create(CreateOutboundTrunkParams {
name: "provider".into(),
address: "sip.telnyx.com:5061".into(),
numbers: vec!["+15550002".into()],
transport: Some(SipTransport::TLS),
..Default::default()
})
.await
.unwrap();
}
#[tokio::test]
async fn updating_a_trunk_omits_absent_fields() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/v2/sip/inbound-gateways/gw-1"))
.and(body_json(json!({"name": "renamed", "record": false})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "gw-1"})))
.expect(1)
.mount(&server)
.await;
client(&server)
.sip()
.trunks()
.inbound()
.update(
"gw-1",
UpdateInboundTrunkParams {
name: Some("renamed".into()),
record: Some(false),
..Default::default()
},
)
.await
.unwrap();
}
#[tokio::test]
async fn deleting_a_trunk_returns_the_message_body() {
let server = MockServer::start().await;
mount(
&server,
"DELETE",
"/v2/sip/outbound-gateways/gw-2",
json!({"message": "deleted"}),
)
.await;
let response = client(&server)
.sip()
.trunks()
.outbound()
.delete("gw-2")
.await
.unwrap();
assert_eq!(response.message, "deleted");
}
#[tokio::test]
async fn listing_trunks_forwards_id_and_search() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/sip/inbound-gateways",
json!({"data": [{"id": "gw-1"}]}),
)
.await;
let page = client(&server)
.sip()
.trunks()
.inbound()
.list(SipTrunkListParams {
id: Some("gw-1".into()),
search: Some("main".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(page.data.len(), 1);
let request = only_request(&server).await;
let query: std::collections::HashMap<_, _> = request.url.query_pairs().collect();
assert_eq!(query["id"], "gw-1");
assert_eq!(query["search"], "main");
}
#[tokio::test]
async fn a_trunk_tolerates_an_unknown_region_and_transport() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/sip/inbound-gateways/gw-1",
json!({"id": "gw-1", "geoRegion": "mars001", "transport": "quic"}),
)
.await;
let trunk = client(&server)
.sip()
.trunks()
.inbound()
.get("gw-1")
.await
.unwrap();
assert_eq!(trunk.geo_region.unwrap().as_str(), "mars001");
assert_eq!(trunk.transport.unwrap().as_str(), "quic");
}
#[tokio::test]
async fn creating_a_routing_rule_expands_the_direct_target() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/sip/routing-rule"))
.and(body_json(json!({
"name": "support",
"type": "inbound",
"phoneNumbers": ["+15550001"],
"room": {"type": "static", "id": "abcd-efgh-ijkl"},
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "rule-1"})))
.expect(1)
.mount(&server)
.await;
let rule = client(&server)
.sip()
.routing_rules()
.create(CreateRoutingRuleParams {
name: "support".into(),
direction: Some(SipDirection::INBOUND),
phone_numbers: vec!["+15550001".into()],
rule: Some(RoutingRuleTarget::Direct {
room_id: "abcd-efgh-ijkl".into(),
pin: None,
}),
..Default::default()
})
.await
.unwrap();
assert_eq!(rule.id, "rule-1");
}
#[tokio::test]
async fn updating_a_routing_rule_never_sends_the_direction() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/v2/sip/routing-rule/rule-1"))
.and(body_json(json!({
"room": {"type": "dynamic", "prefix": "sipNumber"},
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "rule-1"})))
.expect(1)
.mount(&server)
.await;
client(&server)
.sip()
.routing_rules()
.update(
"rule-1",
UpdateRoutingRuleParams {
rule: Some(RoutingRuleTarget::Individual {
room_prefix: Some(crate::SipRoomPrefix::SIP_NUMBER),
pin: None,
}),
..Default::default()
},
)
.await
.unwrap();
}
#[tokio::test]
async fn listing_routing_rules_comma_joins_its_list_filters() {
let server = MockServer::start().await;
mount(&server, "GET", "/v2/sip/routing-rule", json!({"data": []})).await;
client(&server)
.sip()
.routing_rules()
.list(SipRoutingRuleListParams {
direction: Some(SipDirection::OUTBOUND),
rule_ids: vec!["r-1".into(), "r-2".into()],
room_type: vec![SipRoomType::STATIC, SipRoomType::DYNAMIC],
..Default::default()
})
.await
.unwrap();
let request = only_request(&server).await;
let query: std::collections::HashMap<_, _> = request.url.query_pairs().collect();
assert_eq!(query["type"], "outbound");
assert_eq!(query["ruleIds"], "r-1,r-2");
assert_eq!(query["roomType"], "static,dynamic");
}
#[tokio::test]
async fn a_routing_rule_response_decodes_its_room_config() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/sip/routing-rule/rule-1",
json!({
"id": "rule-1",
"type": "inbound",
"room": {"type": "dynamic", "prefix": "random"},
"allowedIpAddresses": ["1.2.3.4"],
}),
)
.await;
let rule = client(&server)
.sip()
.routing_rules()
.get("rule-1")
.await
.unwrap();
assert_eq!(rule.direction, Some(SipDirection::INBOUND));
assert_eq!(rule.room.unwrap().prefix.unwrap().as_str(), "random");
assert_eq!(rule.allowed_ip_addresses, ["1.2.3.4"]);
}
#[tokio::test]
async fn provisioning_phone_numbers_sends_both_gateway_configs() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/sip/phone-numbers"))
.and(body_json(json!({
"name": "main",
"phoneNumbers": ["+15550001"],
"inbound": {"sipRegion": "auto"},
"outbound": {"address": "sip.example.com", "sipRegion": "us002"},
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "created"})))
.expect(1)
.mount(&server)
.await;
let response = client(&server)
.sip()
.phone_numbers()
.create(CreatePhoneNumbersParams {
name: "main".into(),
phone_numbers: vec!["+15550001".into()],
is_shared: None,
media_encryption: None,
inbound: PhoneNumberInboundConfig {
sip_region: SipRegion::AUTO,
},
outbound: PhoneNumberOutboundConfig {
address: "sip.example.com".into(),
sip_region: SipRegion::US002,
auth: None,
transport: None,
},
})
.await
.unwrap();
assert_eq!(response.message, "created");
}
#[tokio::test]
async fn getting_a_phone_number_returns_its_gateways() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/sip/phone-numbers/%2B15550001",
json!({
"phoneNumber": {"phoneNumberId": "pn-1", "e164": "+15550001"},
"inbound": {"id": "gw-1"},
"outbound": null,
}),
)
.await;
let number = client(&server)
.sip()
.phone_numbers()
.get("+15550001")
.await
.unwrap();
assert_eq!(number.phone_number.e164, "+15550001");
assert_eq!(number.inbound.unwrap().id, "gw-1");
assert!(number.outbound.is_none());
}
#[tokio::test]
async fn detaching_and_releasing_hit_their_routes() {
let server = MockServer::start().await;
mount(
&server,
"POST",
"/v2/sip/phone-numbers/detach",
json!({"message": "detached"}),
)
.await;
mount(
&server,
"POST",
"/v2/sip/phone-numbers/pn-1/release",
json!({"message": "released"}),
)
.await;
let client = client(&server);
assert_eq!(
client
.sip()
.phone_numbers()
.detach("+15550001")
.await
.unwrap()
.message,
"detached"
);
assert_eq!(
client
.sip()
.phone_numbers()
.release("pn-1")
.await
.unwrap()
.message,
"released"
);
let detach = &requests(&server).await[0];
assert_eq!(body(detach), json!({"phoneNumber": "+15550001"}));
}
#[tokio::test]
async fn creating_a_call_renames_its_fields_and_unwraps_data() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/sip/call"))
.and(body_json(json!({
"sipCallTo": "+15550003",
"sipCallFrom": "+15550001",
"destinationRoomId": "r-1",
"participant": {"id": "p-1"},
})))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"data": {"id": "call-1", "status": "initiated"}})),
)
.expect(1)
.mount(&server)
.await;
let call = client(&server)
.sip()
.calls()
.create(CreateSipCallParams {
call_to: "+15550003".into(),
call_from: Some("+15550001".into()),
room_id: Some("r-1".into()),
participant_id: Some("p-1".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(call.id, "call-1");
assert_eq!(call.status.unwrap().as_str(), "initiated");
}
#[tokio::test]
async fn creating_a_call_requires_a_destination() {
let server = MockServer::start().await;
let err = client(&server)
.sip()
.calls()
.create(CreateSipCallParams::default())
.await
.unwrap_err();
assert!(err.is_validation());
assert!(err.to_string().contains("call_to"));
assert!(requests(&server).await.is_empty());
}
#[tokio::test]
async fn listing_calls_surfaces_call_id_as_id() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/sip/call",
json!({"data": [{"callId": "c-1", "to": "+1"}, {"callId": "c-2"}]}),
)
.await;
let page = client(&server)
.sip()
.calls()
.list(SipCallListParams {
direction: Some(SipDirection::OUTBOUND),
start_date: Some(1_700_000_000_000),
..Default::default()
})
.await
.unwrap();
assert_eq!(page.data[0].id, "c-1");
assert_eq!(page.data[1].id, "c-2");
let request = only_request(&server).await;
let query: std::collections::HashMap<_, _> = request.url.query_pairs().collect();
assert_eq!(query["type"], "outbound");
assert_eq!(query["startDate"], "1700000000000");
}
#[tokio::test]
async fn a_call_stream_walks_pages_and_maps_ids() {
let server = MockServer::start().await;
let page = |current: u32, id: &str| {
json!({
"pageInfo": {"currentPage": current, "perPage": 1, "lastPage": 2, "total": 2},
"data": [{"callId": id}],
})
};
Mock::given(method("GET"))
.and(query_param("page", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(2, "c-2")))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(1, "c-1")))
.mount(&server)
.await;
let client = client(&server);
let ids: Vec<String> = client
.sip()
.calls()
.list_stream(SipCallListParams::default())
.map(|call| call.unwrap().id)
.collect()
.await;
assert_eq!(ids, ["c-1", "c-2"]);
}
#[tokio::test]
async fn transfer_end_and_switch_room_unwrap_data() {
let server = MockServer::start().await;
for route in ["transfer", "end", "switch-room"] {
mount(
&server,
"POST",
&format!("/v2/sip/call/{route}"),
json!({"data": {"id": "call-1"}}),
)
.await;
}
let client = client(&server);
let call = client
.sip()
.calls()
.transfer(
"call-1",
TransferSipCallParams {
to: Some("+15550009".into()),
play_dialtone: Some(true),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(call.id, "call-1");
client.sip().calls().end("call-1").await.unwrap();
client
.sip()
.calls()
.switch_room(
"call-1",
SwitchRoomParams {
room_id: "r-2".into(),
..Default::default()
},
)
.await
.unwrap();
let sent = requests(&server).await;
assert_eq!(
body(&sent[0]),
json!({"callId": "call-1", "transferTo": "+15550009", "playDialtone": true})
);
assert_eq!(body(&sent[1]), json!({"callId": "call-1"}));
assert_eq!(body(&sent[2]), json!({"callId": "call-1", "roomId": "r-2"}));
}
#[tokio::test]
async fn a_call_tolerates_an_unknown_status() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/sip/call/c-1",
json!({"data": {"id": "c-1", "status": "some-future-status"}}),
)
.await;
let call = client(&server).sip().calls().get("c-1").await.unwrap();
assert_eq!(call.status.unwrap().as_str(), "some-future-status");
}
#[tokio::test]
async fn creating_a_sip_webhook_sends_url_and_events() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/sip/webhooks"))
.and(body_json(json!({
"url": "https://example.com/sip",
"events": ["call-started", "call-hangup"],
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "w-1"})))
.expect(1)
.mount(&server)
.await;
let webhook = client(&server)
.sip()
.webhooks()
.create(CreateSipWebhookParams {
url: "https://example.com/sip".into(),
events: vec![SipWebhookEvent::CALL_STARTED, SipWebhookEvent::CALL_HANGUP],
})
.await
.unwrap();
assert_eq!(webhook.id, "w-1");
}
#[tokio::test]
async fn listing_sip_webhooks_surfaces_webhook_id_as_id() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/sip/webhooks",
json!({"data": [{"webhookId": "w-1", "url": "https://x", "events": ["call-started"]}]}),
)
.await;
let page = client(&server)
.sip()
.webhooks()
.list(SipWebhookListParams {
search: Some("example".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(page.data[0].id, "w-1");
assert_eq!(page.data[0].events[0], SipWebhookEvent::CALL_STARTED);
}
#[tokio::test]
async fn updating_and_deleting_a_sip_webhook_hit_their_routes() {
let server = MockServer::start().await;
mount(
&server,
"PATCH",
"/v2/sip/webhooks/w-1",
json!({"id": "w-1"}),
)
.await;
mount(
&server,
"DELETE",
"/v2/sip/webhooks/w-1",
json!({"message": "deleted"}),
)
.await;
let client = client(&server);
client
.sip()
.webhooks()
.update(
"w-1",
CreateSipWebhookParams {
url: "https://example.com/new".into(),
events: vec![SipWebhookEvent::CALL_MISSED],
},
)
.await
.unwrap();
assert_eq!(
client.sip().webhooks().delete("w-1").await.unwrap().message,
"deleted"
);
}