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::pagination::ListParams;
use crate::resources::rooms::{
AutoCloseConfigInput, RoomCreateParams, RoomListParams, RoomWebhookInput,
};
use crate::resources::sessions::{
SessionEndParams, SessionListParams, SessionRemoveParticipantParams, SessionStatus,
};
use crate::test_support::{body, client, only_request, requests};
use crate::Region;
fn room_json(room_id: &str) -> Value {
json!({"id": "db-1", "roomId": room_id})
}
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 create_room_posts_the_mapped_wire_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/rooms"))
.and(body_json(json!({
"customRoomId": "my-room",
"geoFence": "in002",
"webhook": {"endPoint": "https://example.com/hook", "events": ["session*"]},
"autoCloseConfig": {"type": "session-ends", "duration": 300},
})))
.respond_with(ResponseTemplate::new(200).set_body_json(room_json("abcd-efgh-ijkl")))
.expect(1)
.mount(&server)
.await;
let room = client(&server)
.rooms()
.create(RoomCreateParams {
custom_room_id: Some("my-room".into()),
geo_fence: Some(Region::IN002),
webhook: Some(RoomWebhookInput {
url: "https://example.com/hook".into(),
events: vec!["session*".into()],
}),
auto_close_config: Some(AutoCloseConfigInput {
after_inactive_seconds: Some(300),
kind: None,
}),
..Default::default()
})
.await
.unwrap();
assert_eq!(room.room_id, "abcd-efgh-ijkl");
}
#[tokio::test]
async fn get_room_escapes_the_path_segment() {
let server = MockServer::start().await;
mount(&server, "GET", "/v2/rooms/a%2Fb", room_json("a/b")).await;
client(&server).rooms().get("a/b").await.unwrap();
assert_eq!(only_request(&server).await.url.path(), "/v2/rooms/a%2Fb");
}
#[tokio::test]
async fn validate_reports_a_valid_room() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/rooms/validate/my-room",
room_json("abcd-efgh-ijkl"),
)
.await;
let validation = client(&server).rooms().validate("my-room").await.unwrap();
assert!(validation.valid);
assert_eq!(validation.room_id, "abcd-efgh-ijkl");
assert!(validation.room.is_some());
}
#[tokio::test]
async fn validate_treats_400_402_and_404_as_invalid_rather_than_an_error() {
for status in [400, 402, 404] {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(status))
.mount(&server)
.await;
let validation = client(&server)
.rooms()
.validate("nope")
.await
.unwrap_or_else(|e| panic!("status {status} should not error: {e}"));
assert!(!validation.valid);
assert_eq!(validation.room_id, "nope");
assert!(validation.room.is_none());
}
}
#[tokio::test]
async fn validate_propagates_other_errors() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let err = client(&server).rooms().validate("x").await.unwrap_err();
assert!(err.is_authentication());
}
#[tokio::test]
async fn end_room_posts_to_deactivate() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/rooms/deactivate"))
.and(body_json(json!({"roomId": "abcd-efgh-ijkl"})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "db-1", "roomId": "abcd-efgh-ijkl", "disabled": true,
})))
.expect(1)
.mount(&server)
.await;
let room = client(&server).rooms().end("abcd-efgh-ijkl").await.unwrap();
assert!(room.disabled);
}
#[tokio::test]
async fn list_rooms_sends_filters_and_omits_absent_ones() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/rooms"))
.and(query_param("page", "2"))
.and(query_param("query", "my-room"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [room_json("r-1")]})))
.expect(1)
.mount(&server)
.await;
let page = client(&server)
.rooms()
.list(RoomListParams {
page: Some(2),
query: Some("my-room".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(page.data.len(), 1);
let url = only_request(&server).await.url;
assert!(!url.query().unwrap().contains("userId"));
}
#[tokio::test]
async fn list_rooms_streams_across_pages() {
let server = MockServer::start().await;
let page = |current: u32, id: &str| {
json!({
"pageInfo": {"currentPage": current, "perPage": 1, "lastPage": 2, "total": 2},
"data": [room_json(id)],
})
};
Mock::given(method("GET"))
.and(query_param("page", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(2, "r-2")))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(1, "r-1")))
.mount(&server)
.await;
let client = client(&server);
let ids: Vec<String> = client
.rooms()
.list_stream(RoomListParams::default())
.map(|room| room.unwrap().room_id)
.collect()
.await;
assert_eq!(ids, ["r-1", "r-2"]);
}
#[tokio::test]
async fn a_list_stream_surfaces_the_first_fetch_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let client = client(&server);
let results: Vec<_> = client
.rooms()
.list_stream(RoomListParams::default())
.collect()
.await;
assert_eq!(results.len(), 1);
assert!(results[0].as_ref().unwrap_err().is_permission());
}
#[tokio::test]
async fn list_sessions_sends_every_filter() {
let server = MockServer::start().await;
mount(&server, "GET", "/v2/sessions", json!({"data": []})).await;
client(&server)
.sessions()
.list(SessionListParams {
room_id: Some("r-1".into()),
custom_room_id: Some("my-room".into()),
user_id: Some("u-1".into()),
start_date: Some(1_700_000_000_000),
end_date: Some(1_800_000_000_000),
status: Some(SessionStatus::ENDED),
..Default::default()
})
.await
.unwrap();
let url = only_request(&server).await.url;
let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
assert_eq!(query["roomId"], "r-1");
assert_eq!(query["customRoomId"], "my-room");
assert_eq!(query["userId"], "u-1");
assert_eq!(query["startDate"], "1700000000000");
assert_eq!(query["endDate"], "1800000000000");
assert_eq!(query["status"], "ended");
}
#[tokio::test]
async fn end_session_folds_the_meeting_id_alias_and_derives_status() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/sessions/end"))
.and(body_json(
json!({"roomId": "abcd-efgh-ijkl", "vsdkForceKill": true}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "s-1",
"meetingId": "abcd-efgh-ijkl",
"userMeetingId": "my-room",
"end": "2026-01-01T00:00:00Z",
})))
.expect(1)
.mount(&server)
.await;
let session = client(&server)
.sessions()
.end(SessionEndParams {
meeting_id: Some("abcd-efgh-ijkl".into()),
force: Some(true),
..Default::default()
})
.await
.unwrap();
assert_eq!(session.room_id, "abcd-efgh-ijkl");
assert_eq!(session.custom_room_id.as_deref(), Some("my-room"));
assert_eq!(session.status, Some(SessionStatus::ENDED));
}
#[tokio::test]
async fn end_session_requires_a_room_or_meeting_id() {
let server = MockServer::start().await;
let err = client(&server)
.sessions()
.end(SessionEndParams::default())
.await
.unwrap_err();
assert!(err.is_validation());
assert!(err.to_string().contains("room_id"));
assert!(requests(&server).await.is_empty());
}
#[tokio::test]
async fn list_session_participants_lifts_them_out_of_the_session_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/sessions/s-1/participants"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"pageInfo": {"currentPage": 1, "perPage": 10, "lastPage": 1, "total": 2},
"data": {"id": "s-1", "participants": [
{"participantId": "p-1", "name": "Ada"},
{"participantId": "p-2"},
]},
})))
.mount(&server)
.await;
let page = client(&server)
.sessions()
.list_participants("s-1", ListParams::default())
.await
.unwrap();
assert_eq!(page.data.len(), 2);
assert_eq!(page.data[0].participant_id, "p-1");
assert_eq!(page.data[0].name, "Ada");
assert_eq!(page.data[1].name, "", "an absent name defaults to empty");
assert_eq!(page.total(), 2);
}
#[tokio::test]
async fn list_active_session_participants_hits_the_active_route() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/sessions/s-1/participants/active",
json!({"data": {"participants": []}}),
)
.await;
let page = client(&server)
.sessions()
.list_active_participants("s-1", ListParams::default())
.await
.unwrap();
assert!(page.data.is_empty());
}
#[tokio::test]
async fn remove_participant_requires_a_room_or_session_id() {
let server = MockServer::start().await;
let err = client(&server)
.sessions()
.remove_participant(SessionRemoveParticipantParams {
participant_id: "p-1".into(),
..Default::default()
})
.await
.unwrap_err();
assert!(err.is_validation());
assert!(requests(&server).await.is_empty());
}
#[tokio::test]
async fn remove_participant_returns_the_confirmation_message() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/sessions/participants/remove"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!("Participant removed")))
.mount(&server)
.await;
let message = client(&server)
.sessions()
.remove_participant(SessionRemoveParticipantParams {
participant_id: "p-1".into(),
session_id: Some("s-1".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(message, "Participant removed");
assert_eq!(
body(&only_request(&server).await),
json!({"participantId": "p-1", "sessionId": "s-1"})
);
}
#[tokio::test]
async fn session_stats_are_returned_untyped() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/sessions/s-1/stats",
json!({"score": 4.8, "nested": {"a": 1}}),
)
.await;
let stats = client(&server).sessions().get_stats("s-1").await.unwrap();
assert_eq!(stats["score"], json!(4.8));
}
#[tokio::test]
async fn participant_stats_use_the_singular_participant_path() {
let server = MockServer::start().await;
mount(
&server,
"GET",
"/v2/sessions/s-1/participant/p-1/stats",
json!({}),
)
.await;
client(&server)
.sessions()
.get_participant_stats("s-1", "p-1")
.await
.unwrap();
}
async fn mount_active_session(server: &MockServer, session_id: Option<&str>) {
let data = match session_id {
Some(id) => json!([{ "id": id }]),
None => json!([]),
};
Mock::given(method("GET"))
.and(path("/v2/sessions"))
.and(query_param("status", "ongoing"))
.and(query_param("roomId", "r-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": data })))
.mount(server)
.await;
}
#[tokio::test]
async fn room_participants_resolve_the_active_session_first() {
let server = MockServer::start().await;
mount_active_session(&server, Some("s-9")).await;
mount(
&server,
"GET",
"/v2/sessions/s-9/participants/active",
json!({"data": {"participants": [{"participantId": "p-1"}]}}),
)
.await;
let page = client(&server)
.participants()
.list("r-1", ListParams::default())
.await
.unwrap();
assert_eq!(page.data[0].participant_id, "p-1");
}
#[tokio::test]
async fn room_participants_are_an_empty_page_with_no_live_session() {
let server = MockServer::start().await;
mount_active_session(&server, None).await;
let page = client(&server)
.participants()
.list("r-1", ListParams::default())
.await
.unwrap();
assert!(page.data.is_empty());
assert!(!page.has_next_page());
assert_eq!(requests(&server).await.len(), 1);
}
#[tokio::test]
async fn room_participant_stream_yields_nothing_with_no_live_session() {
let server = MockServer::start().await;
mount_active_session(&server, None).await;
let client = client(&server);
let items: Vec<_> = client
.participants()
.list_stream("r-1", ListParams::default())
.collect()
.await;
assert!(items.is_empty());
}
#[tokio::test]
async fn room_participant_stream_walks_pages() {
let server = MockServer::start().await;
mount_active_session(&server, Some("s-9")).await;
let page = |current: u32, id: &str| {
json!({
"pageInfo": {"currentPage": current, "perPage": 1, "lastPage": 2, "total": 2},
"data": {"participants": [{"participantId": id}]},
})
};
Mock::given(method("GET"))
.and(path("/v2/sessions/s-9/participants/active"))
.and(query_param("page", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(2, "p-2")))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/sessions/s-9/participants/active"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(1, "p-1")))
.mount(&server)
.await;
let client = client(&server);
let ids: Vec<String> = client
.participants()
.list_stream("r-1", ListParams::default())
.map(|p| p.unwrap().participant_id)
.collect()
.await;
assert_eq!(ids, ["p-1", "p-2"]);
}
#[tokio::test]
async fn getting_a_room_participant_without_a_live_session_is_not_found() {
let server = MockServer::start().await;
mount_active_session(&server, None).await;
let err = client(&server)
.participants()
.get("r-1", "p-1")
.await
.unwrap_err();
assert!(err.is_not_found());
assert!(err.to_string().contains("no active session"));
assert_eq!(err.status(), None);
}
#[tokio::test]
async fn removing_a_room_participant_lets_the_server_resolve_the_session() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/sessions/participants/remove"))
.respond_with(ResponseTemplate::new(200).set_body_string("Removed"))
.mount(&server)
.await;
let message = client(&server)
.participants()
.remove("r-1", "p-1")
.await
.unwrap();
assert_eq!(message, "Removed");
let request = only_request(&server).await;
assert_eq!(
body(&request),
json!({"roomId": "r-1", "participantId": "p-1"})
);
}