You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/synctv-core/tests/room_service_tests.rs

7433 lines
228 KiB
Rust

//! `RoomService` integration tests
//!
//! Tests the `RoomService` business logic layer with real `PostgreSQL` via testcontainers.
//!
use std::sync::Arc;
use chrono::Utc;
use sqlx::PgPool;
use synctv_core::{
cache::{KeyBuilder, UsernameCache},
models::{
room_settings::{AllowAutoJoin, MaxMembers, RequireApproval},
DeletionSource, Media, MediaId, MemberStatus, MyRoomListQuery, PageParams, Playlist,
PlaylistId, ReviewRequestId, RoomAdminPermissionBits, RoomId, RoomListQuery, RoomRole,
RoomSettings, RoomStatus, SourceProvider, UpsertRoomCategory, UpsertRoomLabel, User,
UserId, UserRole, UserStatus,
},
repository::{
MediaRepository, PlaylistRepository, ReviewRepository, RoomMemberRepository,
RoomPasswordRepository, RoomRepository, RoomSettingsRepository, SettingsRepository,
UserRepository,
},
service::{
BruteForceProtection, CreateRoomWithTaxonomyRequest, GuestKickReason,
InMemoryTokenBlacklistStore, JwtService, RoomCategoryUpdate, RoomEvent, RoomPasswordPolicy,
RoomService, RoomServiceOptions, RuntimeSettingsStore, SettingsService, UserService,
},
Error,
};
use synctv_core_testing::{create_test_pool, TestOptionExt, TestResultExt};
async fn expire_kick_cooldown(pool: &PgPool, room_id: RoomId, user_id: UserId) {
sqlx::query!(
"UPDATE room_member_kick_cooldowns
SET ends_at = CURRENT_TIMESTAMP - INTERVAL '1 second'
WHERE room_id = $1 AND user_id = $2",
room_id as RoomId,
user_id as UserId,
)
.execute(pool)
.await
.checked("test operation should succeed");
}
fn assert_f64_eq(actual: f64, expected: f64) {
assert!(
(actual - expected).abs() < f64::EPSILON,
"expected {expected}, got {actual}"
);
}
fn u64_to_i64(value: u64) -> i64 {
i64::try_from(value).unwrap_or(i64::MAX)
}
fn make_user_service(pool: &PgPool) -> UserService {
// 32-byte secret for HS256
let secret = "Test_Secret_Key_For_JWT_Tokens_32Bytes!!";
let jwt_service = JwtService::new(secret).checked("Failed to create JwtService");
let username_cache = UsernameCache::local_only("test:username:".to_string(), 100, 60);
let token_blacklist = Arc::new(InMemoryTokenBlacklistStore::new(1000, 3600, 86400));
let key_builder = KeyBuilder::new("test");
let brute_force = BruteForceProtection::in_memory("test".to_string());
UserService::new_for_tests(
pool,
jwt_service,
username_cache,
token_blacklist,
key_builder,
brute_force,
)
}
fn make_room_service(pool: PgPool) -> RoomService {
let user_service = make_user_service(&pool);
RoomService::new_for_tests(pool, user_service).checked("room service should build")
}
fn make_room_service_with_runtime_settings_store(
pool: &PgPool,
runtime_settings_store: Arc<RuntimeSettingsStore>,
) -> RoomService {
let user_service = make_user_service(pool);
RoomService::new_with_options(
pool.clone(),
user_service,
RoomServiceOptions {
runtime_settings_store: Some(runtime_settings_store),
..RoomServiceOptions::test_defaults_with_settings(pool.clone())
},
)
.checked("room service should build")
}
async fn register_direct_url_provider(room_service: &RoomService) {
if let Err(error) = room_service
.media_service()
.providers_manager()
.create_provider_with_default_config("direct_url", "direct_url")
.await
{
std::panic::panic_any(format!("direct_url provider should register: {error:?}"));
}
}
fn make_user(username: &str) -> User {
let now = Utc::now();
User {
id: UserId::new(),
username: username.to_string(),
role: UserRole::User,
avatar_file_reference_id: None,
status: UserStatus::Active,
signup_method: synctv_core::models::SignupMethod::Email,
created_at: now,
updated_at: now,
version: 0,
deleted_at: None,
is_banned: false,
banned_at: None,
banned_by: None,
banned_reason: None,
}
}
fn make_media(
room_id: RoomId,
creator_id: UserId,
playlist_id: Option<PlaylistId>,
name: &str,
) -> Media {
let now = Utc::now();
Media {
id: MediaId::new(),
playlist_id,
room_id,
name: name.to_string(),
description: String::new(),
position: 0.0,
source_provider: SourceProvider::DirectUrl,
source_config: synctv_core_testing::direct_url_media_source_config(
"https://example.com/video.mp4",
),
provider_instance_name: None,
creator_id: Some(creator_id),
cover_file_reference_id: None,
thumbnail_file_reference_id: None,
added_at: now,
updated_at: now,
version: 0,
}
}
fn make_playlist(
room_id: RoomId,
creator_id: UserId,
parent_id: Option<PlaylistId>,
name: &str,
) -> Playlist {
let now = Utc::now();
Playlist {
id: PlaylistId::new(),
room_id,
creator_id: Some(creator_id),
name: name.to_string(),
description: String::new(),
cover_file_reference_id: None,
parent_id,
position: 0.0,
source_provider: None,
source_config: None,
provider_instance_name: None,
created_at: now,
updated_at: now,
version: 0,
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_create_room_initializes_password_settings_and_playlist_state() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let password_repo = RoomPasswordRepository::new(pool.clone());
let password_owner = user_repo
.create(&make_user("pwd_owner"))
.await
.checked("test operation should succeed");
let open_owner = user_repo
.create(&make_user("nopwd_owner"))
.await
.checked("test operation should succeed");
let (password_room, password_member) = room_service
.create_room(
"Password Room".to_string(),
"A password-protected room".to_string(),
password_owner.id,
Some("MySecretPassword123".to_string()),
None,
)
.await
.checked("test operation should succeed");
let (open_room, open_member) = room_service
.create_room(
"No Password Room".to_string(),
"An open room".to_string(),
open_owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
assert_eq!(password_room.name, "Password Room");
assert_eq!(password_member.role, RoomRole::Creator);
assert_eq!(open_room.name, "No Password Room");
assert_eq!(open_member.role, RoomRole::Creator);
let credential = password_repo
.get_opaque_credential(&password_room.id)
.await
.checked("test operation should succeed")
.checked("OPAQUE room password credential should be stored");
assert!(!credential.record.record.is_empty());
assert!(!credential.record.credential_identifier.is_empty());
assert_eq!(credential.state.version, 1);
assert!(credential.state.enabled);
assert!(
password_repo
.get_opaque_credential(&open_room.id)
.await
.checked("test operation should succeed")
.is_none(),
"open room should not store OPAQUE credentials"
);
assert!(
password_repo
.get_state(&open_room.id)
.await
.checked("test operation should succeed")
.is_none(),
"open room should not store password state"
);
let (settings, version) = room_service
.get_room_settings_with_version(&open_room.id)
.await
.checked("test operation should succeed");
assert!(settings.chat_enabled.0);
assert!(settings.allow_auto_join.0);
assert!(!settings.allow_guest_join.0);
assert_eq!(
settings.max_members.0,
RoomSettings::default().max_members.0
);
assert_eq!(version, 1);
let room_ids = vec![password_room.id.as_i64(), open_room.id.as_i64()];
let playlist_count: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM playlists WHERE room_id = ANY($1::BIGINT[])"#,
&room_ids
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(playlist_count, 0);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_join_room_correct_password() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("join_owner"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("join_user"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Join Test Room".to_string(),
String::new(),
owner.id,
Some("CorrectPassword123".to_string()),
None,
)
.await
.checked("test operation should succeed");
let (joined_room, member, members) = room_service
.join_room(room.id, joiner.id, Some("CorrectPassword123".to_string()))
.await
.checked("test operation should succeed");
assert_eq!(joined_room.id, room.id);
assert_eq!(member.user_id, joiner.id);
assert_eq!(member.role, RoomRole::Member);
assert!(
members.len() >= 2,
"Should have at least creator and joiner"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_join_room_wrong_password_rejected() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("wrong_pwd_owner"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("wrong_pwd_joiner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Wrong Pwd Room".to_string(),
String::new(),
owner.id,
Some("CorrectPassword123".to_string()),
None,
)
.await
.checked("test operation should succeed");
let result = room_service
.join_room(room.id, joiner.id, Some("WrongPassword456".to_string()))
.await;
assert!(result.is_err());
match result.failed("operation should fail") {
Error::Authorization(msg) => {
assert!(
msg.contains("Invalid password") || msg.contains("password"),
"Error should mention password: {msg}"
);
}
other => std::panic::panic_any(format!("Expected Authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_join_room_ignores_stale_password_when_room_password_disabled() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("stale_pwd_owner"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("stale_pwd_joiner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Stale Password Room".to_string(),
String::new(),
owner.id,
Some("OldPassword123".to_string()),
None,
)
.await
.checked("test operation should succeed");
room_service
.update_room_password(&room.id, None)
.await
.checked("test operation should succeed");
let result = room_service
.join_room(room.id, joiner.id, Some("WrongPassword456".to_string()))
.await;
assert!(
result.is_ok(),
"disabled room password state should allow joining even when stale credentials remain: {result:?}"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_join_room_password_required_not_provided() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("nopwd_join_owner"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("nopwd_join_user"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Pwd Required Room".to_string(),
String::new(),
owner.id,
Some("SecretPassword123".to_string()),
None,
)
.await
.checked("test operation should succeed");
let result = room_service.join_room(room.id, joiner.id, None).await;
assert!(result.is_err());
match result.failed("operation should fail") {
Error::Authorization(msg) => {
assert!(
msg.contains("Password required") || msg.contains("password"),
"Error should mention password required: {msg}"
);
}
other => std::panic::panic_any(format!("Expected Authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_join_room_requires_approval_returns_pending_membership() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let creator = user_repo
.create(&make_user("approval_creator"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("approval_joiner"))
.await
.checked("test operation should succeed");
let settings = RoomSettings {
require_approval: RequireApproval(true),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Approval Room".to_string(),
String::new(),
creator.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
let (_joined_room, member, members) = room_service
.join_room(room.id, joiner.id, None)
.await
.checked("test operation should succeed");
assert_eq!(member.status, MemberStatus::Active);
assert!(
members.is_empty(),
"pending joins must not broadcast active members"
);
let stored_member = room_service
.member_service()
.get_member(&room.id, &joiner.id)
.await
.checked("test operation should succeed");
assert!(
stored_member.is_none(),
"pending join requests must not create active membership rows"
);
let pending_request_exists = sqlx::query_scalar!(
r#"
SELECT EXISTS (
SELECT 1 FROM room_join_requests
WHERE room_id = $1 AND user_id = $2 AND reviewed_at IS NULL
) AS "exists!"
"#,
room.id.as_i64(),
joiner.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert!(pending_request_exists);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_join_room_rejects_self_join_when_auto_join_disabled() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let creator = user_repo
.create(&make_user("manual_join_creator"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("manual_join_target"))
.await
.checked("test operation should succeed");
let settings = RoomSettings {
allow_auto_join: AllowAutoJoin(false),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Manual Join Room".to_string(),
String::new(),
creator.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
let err = room_service
.join_room(room.id, joiner.id, None)
.await
.failed("self-service join must be blocked when allow_auto_join=false");
match err {
Error::Authorization(message) => {
assert!(
message.contains("does not allow self-service joins"),
"unexpected error message: {message}"
);
}
other => std::panic::panic_any(format!("expected authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_reject_member_marks_membership_rejected_and_allows_reapply() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let creator = user_repo
.create(&make_user("reject_creator"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("reject_joiner"))
.await
.checked("test operation should succeed");
let settings = RoomSettings {
require_approval: RequireApproval(true),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Reject Room".to_string(),
String::new(),
creator.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
let (_joined_room, pending_member, _) = room_service
.join_room(room.id, joiner.id, None)
.await
.checked("test operation should succeed");
assert_eq!(pending_member.status, MemberStatus::Active);
let request_id = sqlx::query_scalar!(
r#"
SELECT id
FROM room_join_requests
WHERE room_id = $1
AND user_id = $2
AND reviewed_at IS NULL
"#,
room.id.as_i64(),
joiner.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
room_service
.reject_join_request(
room.id,
creator.id,
ReviewRequestId::expect_positive(request_id),
Some("not now"),
)
.await
.checked("test operation should succeed");
assert!(
member_repo
.get(&room.id, &joiner.id)
.await
.checked("test operation should succeed")
.is_none(),
"rejected join requests must not create active memberships"
);
let rejected_request_exists = sqlx::query_scalar!(
r#"
SELECT EXISTS (
SELECT 1 FROM room_join_requests
WHERE room_id = $1
AND user_id = $2
AND reviewed_at IS NOT NULL
) AS "exists!"
"#,
room.id.as_i64(),
joiner.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert!(rejected_request_exists);
let (_joined_room, pending_again, _) = room_service
.join_room(room.id, joiner.id, None)
.await
.checked("test operation should succeed");
assert_eq!(pending_again.status, MemberStatus::Active);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_join_review_approval_transition_does_not_touch_new_pending_request_for_stale_id() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let review_repo = ReviewRepository::new(pool.clone());
let creator = user_repo
.create(&make_user("stale_join_approval_creator"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("stale_join_approval_joiner"))
.await
.checked("test operation should succeed");
let settings = RoomSettings {
require_approval: RequireApproval(true),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Stale Join Approval Room".to_string(),
String::new(),
creator.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, joiner.id, None)
.await
.checked("test operation should succeed");
let old_request_id = sqlx::query_scalar!(
r#"
SELECT id
FROM room_join_requests
WHERE room_id = $1
AND user_id = $2
AND reviewed_at IS NULL
"#,
room.id.as_i64(),
joiner.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
let old_request_id = ReviewRequestId::expect_positive(old_request_id);
room_service
.reject_join_request(room.id, creator.id, old_request_id, Some("try later"))
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, joiner.id, None)
.await
.checked("test operation should succeed");
let new_request_id = sqlx::query_scalar!(
r#"
SELECT id
FROM room_join_requests
WHERE room_id = $1
AND user_id = $2
AND reviewed_at IS NULL
"#,
room.id.as_i64(),
joiner.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
let new_request_id = ReviewRequestId::expect_positive(new_request_id);
assert_ne!(
old_request_id, new_request_id,
"reapply must create a distinct pending review row"
);
let mut tx = pool.begin().await.checked("test operation should succeed");
let stale_approval = ReviewRepository::approve_room_join_with_executor(
&mut *tx,
old_request_id,
room.id,
Some(creator.id),
)
.await
.checked("test operation should succeed");
tx.commit().await.checked("test operation should succeed");
assert_eq!(
stale_approval, 0,
"approving a stale review id must not approve another pending request for the same member"
);
let new_status = review_repo
.load_room_join_in_room(new_request_id, room.id)
.await
.checked("test operation should succeed")
.checked("new pending join request should still exist");
assert_eq!(
new_status.status,
synctv_core::models::ReviewStatus::Pending,
"new request must remain pending when the stale id is approved"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_approve_join_request_rejects_room_banned_after_request() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let creator = user_repo
.create(&make_user("join_approval_banned_room_creator"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("join_approval_banned_room_joiner"))
.await
.checked("test operation should succeed");
let settings = RoomSettings {
require_approval: RequireApproval(true),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Join Approval Banned Room".to_string(),
String::new(),
creator.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, joiner.id, None)
.await
.checked("test operation should succeed");
let request_id = sqlx::query_scalar!(
r#"
SELECT id
FROM room_join_requests
WHERE room_id = $1
AND user_id = $2
AND reviewed_at IS NULL
"#,
room.id.as_i64(),
joiner.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
room_service
.ban_room(&room.id, &creator.id)
.await
.checked("test operation should succeed");
let err = room_service
.approve_join_request(
room.id,
creator.id,
ReviewRequestId::expect_positive(request_id),
)
.await
.failed("approval must re-check current room ban state");
assert!(matches!(
err,
Error::Authorization(ref msg) if msg.contains("Room is banned")
));
assert!(
member_repo
.get(&room.id, &joiner.id)
.await
.checked("test operation should succeed")
.is_none(),
"failed approval must not create an active membership"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_leave_room_creator_cannot_leave() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("leave_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Leave Test Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let result = room_service.leave_room(room.id, owner.id).await;
assert!(result.is_err());
match result.failed("operation should fail") {
Error::Authorization(msg) => {
assert!(
msg.contains("creator") || msg.contains("Creator"),
"Error should mention creator: {msg}"
);
}
other => std::panic::panic_any(format!("Expected Authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_leave_room_member_succeeds() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("leave_succ_owner"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("leave_succ_member"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Leave Success Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, joiner.id, None)
.await
.checked("test operation should succeed");
assert!(member_repo
.is_member(&room.id, &joiner.id)
.await
.checked("test operation should succeed"));
room_service
.leave_room(room.id, joiner.id)
.await
.checked("test operation should succeed");
assert!(!member_repo
.is_member(&room.id, &joiner.id)
.await
.checked("test operation should succeed"));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_leave_room_cleans_member_created_media_resources() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("leave_cleanup_owner"))
.await
.checked("test operation should succeed");
let member = user_repo
.create(&make_user("leave_cleanup_member"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Leave Cleanup Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
register_direct_url_provider(&room_service).await;
room_service
.join_room(room.id, member.id, None)
.await
.checked("test operation should succeed");
let playlist = room_service
.playlist_service()
.create_playlist(
room.id,
member.id,
synctv_core::service::CreatePlaylistRequest {
room_id: room.id,
name: "member folder".to_string(),
description: String::new(),
parent_id: None,
source_provider: None,
source_config: None,
provider_instance_name: None,
},
)
.await
.checked("test operation should succeed");
let media = room_service
.media_service()
.add_media(
room.id,
member.id,
synctv_core::service::AddMediaRequest {
playlist_id: Some(playlist.id),
name: "member media".to_string(),
description: String::new(),
source_provider: SourceProvider::DirectUrl,
provider_instance_name: None,
source_config: synctv_core_testing::direct_url_media_source_config(
"https://example.com/video.mp4",
),
},
)
.await
.checked("test operation should succeed");
room_service
.playback_service()
.switch(room.id, owner.id, Some(media.id), None, None)
.await
.checked("test operation should succeed");
let warm_state = room_service
.playback_service()
.get_state(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(warm_state.playing_media_id, Some(media.id));
room_service
.leave_room(room.id, member.id)
.await
.checked("test operation should succeed");
assert!(!member_repo
.is_member(&room.id, &member.id)
.await
.checked("test operation should succeed"));
assert!(playlist_repo
.get_by_id(&playlist.id)
.await
.checked("test operation should succeed")
.is_none());
assert!(media_repo
.get_by_id(&media.id)
.await
.checked("test operation should succeed")
.is_none());
let refreshed_state = room_service
.playback_service()
.get_state(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(refreshed_state.playing_media_id, None);
assert_eq!(refreshed_state.playing_playlist_id, None);
assert!(refreshed_state.target.is_none());
assert!(!refreshed_state.is_playing);
assert_f64_eq(refreshed_state.position, 0.0);
assert_f64_eq(refreshed_state.speed, 1.0);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_kick_member_cleans_resources_and_blocks_until_cooldown_expires() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("kick_cleanup_owner"))
.await
.checked("test operation should succeed");
let target = user_repo
.create(&make_user("kick_cleanup_target"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Kick Cleanup Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
register_direct_url_provider(&room_service).await;
room_service
.join_room(room.id, target.id, None)
.await
.checked("test operation should succeed");
let playlist = room_service
.playlist_service()
.create_playlist(
room.id,
target.id,
synctv_core::service::CreatePlaylistRequest {
room_id: room.id,
name: "target folder".to_string(),
description: String::new(),
parent_id: None,
source_provider: None,
source_config: None,
provider_instance_name: None,
},
)
.await
.checked("test operation should succeed");
let media = room_service
.media_service()
.add_media(
room.id,
target.id,
synctv_core::service::AddMediaRequest {
playlist_id: Some(playlist.id),
name: "target media".to_string(),
description: String::new(),
source_provider: SourceProvider::DirectUrl,
provider_instance_name: None,
source_config: synctv_core_testing::direct_url_media_source_config(
"https://example.com/kick.mp4",
),
},
)
.await
.checked("test operation should succeed");
room_service
.playback_service()
.switch(room.id, owner.id, Some(media.id), None, None)
.await
.checked("test operation should succeed");
let warm_state = room_service
.playback_service()
.get_state(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(warm_state.playing_media_id, Some(media.id));
room_service
.kick_member(room.id, owner.id, target.id, 3600)
.await
.checked("test operation should succeed");
assert!(!member_repo
.is_member(&room.id, &target.id)
.await
.checked("test operation should succeed"));
assert!(member_repo
.is_in_kick_cooldown(&room.id, &target.id)
.await
.checked("test operation should succeed"));
assert!(playlist_repo
.get_by_id(&playlist.id)
.await
.checked("test operation should succeed")
.is_none());
assert!(media_repo
.get_by_id(&media.id)
.await
.checked("test operation should succeed")
.is_none());
let refreshed_state = room_service
.playback_service()
.get_state(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(refreshed_state.playing_media_id, None);
assert_eq!(refreshed_state.playing_playlist_id, None);
assert!(refreshed_state.target.is_none());
assert!(!refreshed_state.is_playing);
assert_f64_eq(refreshed_state.position, 0.0);
assert_f64_eq(refreshed_state.speed, 1.0);
let blocked_rejoin = room_service.join_room(room.id, target.id, None).await;
assert!(
matches!(blocked_rejoin, Err(Error::KickCooldownDenied)),
"rejoin during kick cooldown should be blocked, got {blocked_rejoin:?}"
);
expire_kick_cooldown(&pool, room.id, target.id).await;
assert!(room_service
.join_room(room.id, target.id, None)
.await
.is_ok());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_leave_room_non_member_is_rejected() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("leave_non_member_owner"))
.await
.checked("test operation should succeed");
let outsider = user_repo
.create(&make_user("leave_non_member_outsider"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Leave Non Member Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let result = room_service.leave_room(room.id, outsider.id).await;
assert!(
result.is_err(),
"Non-member should not be able to leave room"
);
match result.failed("operation should fail") {
Error::Authorization(msg) => {
assert!(
msg.contains("not a member") || msg.contains("Not a member"),
"Error should mention membership requirement: {msg}"
);
}
other => std::panic::panic_any(format!("Expected Authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_room_sets_deleted_at() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("delete_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
assert!(room_repo
.exists(&room.id)
.await
.checked("test operation should succeed"));
room_service
.delete_room(room.id, owner.id)
.await
.checked("test operation should succeed");
let fetched = room_repo
.get_by_id(&room.id)
.await
.checked("test operation should succeed");
assert!(
fetched.is_none(),
"Room should not be found after soft-delete"
);
let deleted_at = sqlx::query_scalar!(
"SELECT deleted_at FROM rooms WHERE id = $1",
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert!(deleted_at.is_some(), "deleted_at should be set");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_settings_cas_exhaustion_returns_internal() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("cas_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"CAS Test Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
// Manually corrupt the version to force OptimisticLockConflict on every attempt.
// We do this by updating the version to a very high number after each read.
// The service reads version N, then we immediately bump it, so the CAS write
// fails with OptimisticLockConflict.
// Spawn a concurrent task that keeps bumping the version.
let pool_clone = pool.clone();
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_clone = stop.clone();
let bumper = tokio::spawn(async move {
while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
sqlx::query!(
"UPDATE room_settings SET version = version + 1 WHERE room_id = $1",
room.id.as_i64()
)
.execute(&pool_clone)
.await?;
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
Ok::<_, sqlx::Error>(())
});
let settings = RoomSettings::default();
let result = room_service.set_settings(room.id, owner.id, settings).await;
stop.store(true, std::sync::atomic::Ordering::Relaxed);
bumper
.await
.checked("bumper task should not panic")
.checked("bumper task should update room settings versions");
// The result should be an Internal error (not OptimisticLockConflict)
match result {
Ok(_) => {
// If the bumper didn't run fast enough, the update may have succeeded.
// This is acceptable - the test is probabilistic.
}
Err(Error::Internal(msg)) => {
assert!(
msg.contains("maximum retry"),
"Should mention retry exhaustion: {msg}"
);
}
Err(Error::OptimisticLockConflict) => {
std::panic::panic_any(
"OptimisticLockConflict should not leak; it should be wrapped in Internal error",
);
}
Err(other) => {
std::panic::panic_any(format!("Unexpected error: {other:?}"));
}
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_kicked_user_cannot_rejoin_until_cooldown_expires() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let creator = user_repo
.create(&make_user("ban_rejoin_creator"))
.await
.checked("test operation should succeed");
let target = user_repo
.create(&make_user("ban_rejoin_target"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Ban Rejoin Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, target.id, None)
.await
.checked("test operation should succeed");
room_service
.kick_member(room.id, creator.id, target.id, 3600)
.await
.checked("test operation should succeed");
let result = room_service.join_room(room.id, target.id, None).await;
assert!(
result.is_err(),
"Kicked user should not be able to rejoin during cooldown"
);
match result.failed("operation should fail") {
Error::KickCooldownDenied => {}
other => std::panic::panic_any(format!("Expected kick cooldown error, got: {other:?}")),
}
expire_kick_cooldown(&pool, room.id, target.id).await;
room_service
.join_room(room.id, target.id, None)
.await
.checked("kicked user should rejoin after cooldown expiry");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_with_banned_creator_becomes_unavailable_to_existing_members() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let creator = user_repo
.create(&make_user("inactive_owner_creator"))
.await
.checked("test operation should succeed");
let member = user_repo
.create(&make_user("inactive_owner_member"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Inactive Owner Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, member.id, None)
.await
.checked("test operation should succeed");
user_repo
.ban(&creator.id, None, Some("room service test".to_string()))
.await
.checked("test operation should succeed");
let result = room_service.check_membership(&room.id, &member.id).await;
match result.failed("member access must fail once creator is banned") {
Error::Authorization(message) => {
assert!(
message.contains("creator") && message.contains("active"),
"error should explain creator status: {message}"
);
}
other => std::panic::panic_any(format!("expected authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_with_banned_creator_rejects_new_joins() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let creator = user_repo
.create(&make_user("inactive_join_creator"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("inactive_join_member"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Inactive Join Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
user_repo
.ban(&creator.id, None, Some("room service test".to_string()))
.await
.checked("test operation should succeed");
let result = room_service.join_room(room.id, joiner.id, None).await;
match result.failed("room with banned creator must reject joins") {
Error::Authorization(message) => {
assert!(
message.contains("creator") && message.contains("active"),
"error should explain creator status: {message}"
);
}
other => std::panic::panic_any(format!("expected authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_description_unicode_500_chars_accepted() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("unicode_owner"))
.await
.checked("test operation should succeed");
// 500 Unicode characters (mix of ASCII and accented Latin)
let desc = "Helloß".repeat(50); // 6 chars * 50 = 300 chars
let desc = format!("{}{}", desc, "a".repeat(200)); // 300 + 200 = 500 chars
assert_eq!(desc.chars().count(), 500);
let result = room_service
.create_room(
"Unicode Room".to_string(),
desc.clone(),
owner.id,
None,
None,
)
.await;
assert!(result.is_ok(), "500 Unicode chars should be accepted");
let (room, _) = result.checked("test operation should succeed");
assert_eq!(room.description.chars().count(), 500);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_description_over_500_rejected() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("long_desc_owner"))
.await
.checked("test operation should succeed");
// 501 characters
let desc = "a".repeat(501);
let result = room_service
.create_room("Long Desc Room".to_string(), desc, owner.id, None, None)
.await;
assert!(result.is_err());
match result.failed("operation should fail") {
Error::InvalidInput(msg) => {
assert!(
msg.contains("description") || msg.contains("500"),
"Should mention description limit: {msg}"
);
}
other => std::panic::panic_any(format!("Expected InvalidInput error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_join_room_uses_current_password_state_after_updates() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("pwd_transition_owner"))
.await
.checked("test operation should succeed");
let old_joiner = user_repo
.create(&make_user("pwd_transition_old"))
.await
.checked("test operation should succeed");
let new_joiner = user_repo
.create(&make_user("pwd_transition_new"))
.await
.checked("test operation should succeed");
let clear_joiner = user_repo
.create(&make_user("pwd_transition_clear"))
.await
.checked("test operation should succeed");
let missing_joiner = user_repo
.create(&make_user("pwd_transition_missing"))
.await
.checked("test operation should succeed");
let added_joiner = user_repo
.create(&make_user("pwd_transition_added"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Password Transition Room".to_string(),
String::new(),
owner.id,
Some("OriginalPassword123".to_string()),
None,
)
.await
.checked("test operation should succeed");
room_service
.update_room_password(&room.id, Some("NewPassword456".to_string()))
.await
.checked("password update should succeed");
let result = room_service
.join_room(
room.id,
old_joiner.id,
Some("OriginalPassword123".to_string()),
)
.await;
assert!(matches!(result, Err(Error::Authorization(_))));
let (joined_room, member, _) = room_service
.join_room(room.id, new_joiner.id, Some("NewPassword456".to_string()))
.await
.checked("new password should allow join");
assert_eq!(joined_room.id, room.id);
assert_eq!(member.user_id, new_joiner.id);
room_service
.update_room_password(&room.id, None)
.await
.checked("room password should be disabled");
let (joined_room, member, _) = room_service
.join_room(room.id, clear_joiner.id, None)
.await
.checked("cleared password should allow join");
assert_eq!(joined_room.id, room.id);
assert_eq!(member.user_id, clear_joiner.id);
room_service
.update_room_password(&room.id, Some("AddedPassword123".to_string()))
.await
.checked("password update should succeed");
let result = room_service
.join_room(room.id, missing_joiner.id, None)
.await;
assert!(matches!(result, Err(Error::Authorization(_))));
let (joined_room, member, _) = room_service
.join_room(
room.id,
added_joiner.id,
Some("AddedPassword123".to_string()),
)
.await
.checked("added password should allow join");
assert_eq!(joined_room.id, room.id);
assert_eq!(member.user_id, added_joiner.id);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_concurrent_room_creation_same_name_different_users_succeeds() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let user1 = user_repo
.create(&make_user("concurrent_user1"))
.await
.checked("test operation should succeed");
let user2 = user_repo
.create(&make_user("concurrent_user2"))
.await
.checked("test operation should succeed");
let room_name = "Same Name Room".to_string();
let (result1, result2) = tokio::join!(
room_service.create_room(room_name.clone(), "Desc1".to_string(), user1.id, None, None),
room_service.create_room(room_name.clone(), "Desc2".to_string(), user2.id, None, None)
);
assert!(
result1.is_ok(),
"User1 should create room: {:?}",
result1.err()
);
assert!(
result2.is_ok(),
"User2 should create room: {:?}",
result2.err()
);
let room_count: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM rooms WHERE name = $1 AND deleted_at IS NULL"#,
&room_name
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(room_count, 2, "Both active rooms should exist");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_concurrent_same_user_duplicate_room_name_is_service_prevented() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = Arc::new(make_room_service(pool.clone()));
let user = user_repo
.create(&make_user("same_user_duplicate_concurrent"))
.await
.checked("test operation should succeed");
let room_name = "Repeated Concurrent Name".to_string();
let mut handles = Vec::new();
for i in 0..5 {
let room_service = room_service.clone();
let room_name = room_name.clone();
let user_id = user.id;
handles.push(tokio::spawn(async move {
room_service
.create_room(room_name, format!("Desc {i}"), user_id, None, None)
.await
}));
}
let mut success_count = 0;
let mut already_exists_count = 0;
for handle in handles {
match handle.await.checked("test operation should succeed") {
Ok(_) => success_count += 1,
Err(Error::AlreadyExists(msg)) => {
assert_eq!(msg, "You already have a room with this name");
already_exists_count += 1;
}
Err(err) => std::panic::panic_any(format!("unexpected create_room error: {err:?}")),
}
}
assert_eq!(success_count, 1, "only one service create should succeed");
assert_eq!(
already_exists_count, 4,
"all competing creates should hit service policy"
);
let persisted_count: i64 = sqlx::query_scalar!(
r#"
SELECT COUNT(*) AS "count!"
FROM rooms
WHERE created_by = $1 AND name = $2 AND deleted_at IS NULL
"#,
user.id.as_i64(),
&room_name
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(persisted_count, 1);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_same_user_can_create_multiple_rooms_with_distinct_names() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let user = user_repo
.create(&make_user("same_user_concurrent"))
.await
.checked("test operation should succeed");
let (result1, result2) = tokio::join!(
room_service.create_room(
"User Room A".to_string(),
"Desc1".to_string(),
user.id,
None,
None
),
room_service.create_room(
"Different Room".to_string(),
"Desc2".to_string(),
user.id,
None,
None
)
);
assert!(
result1.is_ok(),
"First room should be created: {:?}",
result1.err()
);
assert!(
result2.is_ok(),
"Second room should be created: {:?}",
result2.err()
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_password_update_invalidates_room_cache() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let settings_repo = RoomSettingsRepository::new(pool.clone());
let password_repo = RoomPasswordRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("pwd_cache_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Password Cache Room".to_string(),
String::new(),
owner.id,
Some("OriginalPassword123".to_string()),
None,
)
.await
.checked("test operation should succeed");
let initial_state = password_repo
.get_state(&room.id)
.await
.checked("test operation should succeed")
.checked("initial OPAQUE credential should exist");
room_service
.update_room_password(&room.id, Some("NewPassword456".to_string()))
.await
.checked("test operation should succeed");
let updated_state = password_repo
.get_state(&room.id)
.await
.checked("test operation should succeed")
.checked("updated OPAQUE credential should exist");
assert!(
updated_state.version > initial_state.version,
"password update should bump room password version"
);
let settings = settings_repo
.get(&room.id)
.await
.checked("test operation should succeed");
assert!(settings.chat_enabled.0);
assert!(
password_repo
.get_state(&room.id)
.await
.checked("test operation should succeed")
.is_some_and(|state| state.enabled),
"Room should still require password after update"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_password_removal_disables_room_password_and_preserves_credential() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let password_repo = RoomPasswordRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("pwd_remove_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Password Remove Room".to_string(),
String::new(),
owner.id,
Some("PasswordToBeRemoved".to_string()),
None,
)
.await
.checked("test operation should succeed");
let initial_credential = password_repo
.get_opaque_credential(&room.id)
.await
.checked("test operation should succeed")
.checked("password credential should be stored");
assert!(initial_credential.state.enabled);
// Remove password
room_service
.update_room_password(&room.id, None)
.await
.checked("test operation should succeed");
let disabled_credential = password_repo
.get_opaque_credential(&room.id)
.await
.checked("test operation should succeed")
.checked("disabling room password should keep OPAQUE credential material");
assert!(!disabled_credential.state.enabled);
assert!(disabled_credential.state.version > initial_credential.state.version);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_room_password_updates_password_state_without_settings_notification() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let mut event_rx = room_service.notification_service().subscribe();
let owner = user_repo
.create(&make_user("password_notify_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Password Notify Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.update_room_password(&room.id, Some("hashed-password".to_string()))
.await
.checked("test operation should succeed");
let password_state = RoomPasswordRepository::new(pool.clone())
.get_state(&room.id)
.await
.checked("test operation should succeed")
.checked("password state should exist");
assert!(password_state.enabled);
let settings_event = tokio::time::timeout(std::time::Duration::from_millis(100), async {
loop {
let received = event_rx
.recv()
.await
.checked("test operation should succeed");
if matches!(received.1, RoomEvent::SettingsUpdated { .. }) {
break received;
}
}
})
.await;
assert!(
settings_event.is_err(),
"password update should not emit settings events"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_kick_member_invalidates_permission_cache() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let creator = user_repo
.create(&make_user("ban_sync_creator"))
.await
.checked("test operation should succeed");
let target = user_repo
.create(&make_user("ban_sync_target"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Ban Sync Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, target.id, None)
.await
.checked("test operation should succeed");
let perm_service = room_service.permission_service();
let initial_perms = perm_service
.get_user_permissions_eventually_consistent(&room.id, &target.id)
.await
.checked("test operation should succeed");
assert!(initial_perms.0 > 0, "Member should have some permissions");
room_service
.kick_member(room.id, creator.id, target.id, 3600)
.await
.checked("test operation should succeed");
assert!(
member_repo
.get_any(&room.id, &target.id)
.await
.checked("test operation should succeed")
.is_none(),
"kick should delete the active membership row"
);
assert!(
member_repo
.is_in_kick_cooldown(&room.id, &target.id)
.await
.checked("test operation should succeed"),
"kick should create an active cooldown"
);
let perms_result = perm_service
.get_user_permissions_no_cache(&room.id, &target.id)
.await;
assert!(
perms_result.is_err(),
"Kicked user should not have permissions"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_kick_prevents_room_access_even_with_cached_permissions() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let creator = user_repo
.create(&make_user("ban_cache_creator"))
.await
.checked("test operation should succeed");
let target = user_repo
.create(&make_user("ban_cache_target"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Ban Cache Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, target.id, None)
.await
.checked("test operation should succeed");
let perm_service = room_service.permission_service();
let _cached = perm_service
.get_user_permissions_eventually_consistent(&room.id, &target.id)
.await
.checked("test operation should succeed");
room_service
.kick_member(room.id, creator.id, target.id, 3600)
.await
.checked("test operation should succeed");
let result = room_service.join_room(room.id, target.id, None).await;
assert!(
result.is_err(),
"Kicked user should not be able to join even with cached permissions"
);
match result.failed("operation should fail") {
Error::KickCooldownDenied => {}
other => std::panic::panic_any(format!("Expected kick cooldown error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_settings_update_retries_on_version_conflict() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("retry_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Retry Settings Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
// Concurrent settings update from two tasks
let room_id1 = room.id;
let room_id2 = room.id;
let user_id1 = owner.id;
let user_id2 = owner.id;
let pool1 = pool.clone();
let update1 = tokio::spawn(async move {
let room_service = make_room_service(pool1.clone());
let mut settings = room_service
.get_room_settings(&room_id1)
.await
.checked("test operation should succeed");
settings.allow_guest_join = synctv_core::models::room_settings::AllowGuestJoin(true);
room_service
.set_settings(room_id1, user_id1, settings)
.await
});
let pool2 = pool.clone();
let update2 = tokio::spawn(async move {
let room_service = make_room_service(pool2.clone());
let mut settings = room_service
.get_room_settings(&room_id2)
.await
.checked("test operation should succeed");
settings.max_members = synctv_core::models::room_settings::MaxMembers(50);
room_service
.set_settings(room_id2, user_id2, settings)
.await
});
let (r1, r2) = tokio::join!(update1, update2);
// At least one should succeed (possibly both with retries)
// The retry mechanism should handle version conflicts
let success_count = u8::from(r1.checked("test operation should succeed").is_ok())
+ u8::from(r2.checked("test operation should succeed").is_ok());
assert!(
success_count >= 1,
"At least one update should succeed with retry mechanism"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_settings_update_returns_internal_error_after_max_retries() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("max_retry_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Max Retry Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
// Manually corrupt the version to force OptimisticLockConflict on every attempt
let pool_clone = pool.clone();
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_clone = stop.clone();
// Spawn a task that keeps bumping the version
let bumper = tokio::spawn(async move {
while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
sqlx::query!(
"UPDATE room_settings SET version = version + 1 WHERE room_id = $1",
room.id.as_i64()
)
.execute(&pool_clone)
.await?;
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
Ok::<_, sqlx::Error>(())
});
let settings = RoomSettings::default();
let result = room_service.set_settings(room.id, owner.id, settings).await;
stop.store(true, std::sync::atomic::Ordering::Relaxed);
bumper
.await
.checked("bumper task should not panic")
.checked("bumper task should update room settings versions");
// The result should be an Internal error (not OptimisticLockConflict which is wrapped)
match result {
Ok(_) => {
// If the bumper didn't run fast enough, the update may have succeeded
// This is acceptable - the test is probabilistic
}
Err(Error::Internal(msg)) => {
assert!(
msg.contains("retry"),
"Should mention retry exhaustion: {msg}"
);
}
Err(Error::OptimisticLockConflict) => {
std::panic::panic_any("OptimisticLockConflict should be wrapped in Internal error");
}
Err(other) => {
std::panic::panic_any(format!("Unexpected error: {other:?}"));
}
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_single_setting_update_with_retry() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("single_setting_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Single Setting Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let mut updated_settings = room_service
.get_room_settings(&room.id)
.await
.checked("test operation should succeed");
updated_settings.allow_guest_join = synctv_core::models::room_settings::AllowGuestJoin(true);
let result = room_service
.set_room_settings(&room.id, &updated_settings)
.await;
assert!(
result.is_ok(),
"Single setting update should succeed: {:?}",
result.err()
);
let settings = room_service
.get_room_settings(&room.id)
.await
.checked("test operation should succeed");
assert!(
settings.allow_guest_join.0,
"allow_guest_join should be true"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_password_update_with_cas_retry() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("pwd_retry_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Password Retry Room".to_string(),
String::new(),
owner.id,
Some("InitialPassword".to_string()),
None,
)
.await
.checked("test operation should succeed");
let result = room_service
.update_room_password(&room.id, Some("NewPassword123".to_string()))
.await;
assert!(
result.is_ok(),
"Password update with CAS retry should succeed: {:?}",
result.err()
);
let joiner = user_repo
.create(&make_user("pwd_retry_joiner"))
.await
.checked("test operation should succeed");
let join_result = room_service
.join_room(room.id, joiner.id, Some("NewPassword123".to_string()))
.await;
assert!(
join_result.is_ok(),
"Join with new password should succeed: {:?}",
join_result.err()
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_deletion_invalidates_caches() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("cache_inval_owner"))
.await
.checked("test operation should succeed");
let member = user_repo
.create(&make_user("cache_inval_member"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Cache Invalidation Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, member.id, None)
.await
.checked("test operation should succeed");
let perm_service = room_service.permission_service();
let _cached = perm_service
.get_user_permissions_eventually_consistent(&room.id, &member.id)
.await
.checked("test operation should succeed");
room_service
.delete_room(room.id, owner.id)
.await
.checked("test operation should succeed");
let room_repo = RoomRepository::new(pool.clone());
let fetched = room_repo
.get_by_id(&room.id)
.await
.checked("test operation should succeed");
assert!(fetched.is_none(), "Room should not be found after deletion");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_password_uses_unique_salt_per_room() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let password_repo = RoomPasswordRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("salt_owner"))
.await
.checked("test operation should succeed");
let (room1, _) = room_service
.create_room(
"Salt Room 1".to_string(),
String::new(),
owner.id,
Some("SamePassword123".to_string()),
None,
)
.await
.checked("test operation should succeed");
let (room2, _) = room_service
.create_room(
"Salt Room 2".to_string(),
String::new(),
owner.id,
Some("SamePassword123".to_string()),
None,
)
.await
.checked("test operation should succeed");
let credential1 = password_repo
.get_opaque_credential(&room1.id)
.await
.checked("test operation should succeed")
.checked("first room credential should exist");
let credential2 = password_repo
.get_opaque_credential(&room2.id)
.await
.checked("test operation should succeed")
.checked("second room credential should exist");
assert_ne!(
credential1.record.record, credential2.record.record,
"OPAQUE credential records should differ per room"
);
assert!(room_service
.check_room_password(&room1.id, "SamePassword123")
.await
.checked("test operation should succeed"));
assert!(room_service
.check_room_password(&room2.id, "SamePassword123")
.await
.checked("test operation should succeed"));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_max_members_enforces_capacity_and_zero_unlimited() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("max_owner"))
.await
.checked("test operation should succeed");
let unlimited_owner = user_repo
.create(&make_user("unlim_owner"))
.await
.checked("test operation should succeed");
let boundary_owner = user_repo
.create(&make_user("boundary_owner"))
.await
.checked("test operation should succeed");
let settings = RoomSettings {
max_members: MaxMembers(3),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Max Members Room".to_string(),
String::new(),
owner.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
let joiner1 = user_repo
.create(&make_user("max_joiner1"))
.await
.checked("test operation should succeed");
let joiner2 = user_repo
.create(&make_user("max_joiner2"))
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, joiner1.id, None)
.await
.checked("first joiner should fit below capacity");
room_service
.join_room(room.id, joiner2.id, None)
.await
.checked("second joiner should reach capacity");
let count = member_repo
.count_by_room(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(count, 3);
let joiner3 = user_repo
.create(&make_user("max_joiner3"))
.await
.checked("test operation should succeed");
let result = room_service.join_room(room.id, joiner3.id, None).await;
assert!(matches!(result, Err(Error::InvalidInput(_))));
let unlimited_settings = RoomSettings {
max_members: MaxMembers(0),
..Default::default()
};
let (unlimited_room, _) = room_service
.create_room(
"Unlimited Room".to_string(),
String::new(),
unlimited_owner.id,
None,
Some(unlimited_settings),
)
.await
.checked("test operation should succeed");
for i in 0..20 {
let joiner = user_repo
.create(&make_user(&format!("unlim_joiner_{i}")))
.await
.checked("test operation should succeed");
room_service
.join_room(unlimited_room.id, joiner.id, None)
.await
.checked("max_members=0 should allow additional members");
}
let boundary_settings = RoomSettings {
max_members: MaxMembers(1),
..Default::default()
};
let (boundary_room, _) = room_service
.create_room(
"Boundary Room".to_string(),
String::new(),
boundary_owner.id,
None,
Some(boundary_settings),
)
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("boundary_joiner"))
.await
.checked("test operation should succeed");
let result = room_service
.join_room(boundary_room.id, joiner.id, None)
.await;
assert!(matches!(result, Err(Error::InvalidInput(_))));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_max_members_cannot_exceed_10000() {
use synctv_core::models::room_settings::{MaxMembers, RoomSettings, RoomSettingsPatch};
let result = {
let mut settings = RoomSettings::default();
synctv_core::models::SettingsValidationContext::with_strict_policy(|ctx| {
settings.apply_patch(
RoomSettingsPatch {
max_members: Some(MaxMembers(10001)),
..Default::default()
},
ctx,
)
})
};
assert!(result.is_err(), "max_members > 10000 should be rejected");
let result = {
let mut settings = RoomSettings::default();
synctv_core::models::SettingsValidationContext::with_strict_policy(|ctx| {
settings.apply_patch(
RoomSettingsPatch {
max_members: Some(MaxMembers(10000)),
..Default::default()
},
ctx,
)
})
};
assert!(result.is_ok(), "max_members = 10000 should be accepted");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_settings_update_validates_permissions_no_escalation() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("perm_esc_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Permission Escalation Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
// Try to set guest permissions that exceed member-level permissions
let mut settings = room_service
.get_room_settings(&room.id)
.await
.checked("test operation should succeed");
settings.guest_added_permissions = synctv_core::models::room_settings::GuestAddedPermissions(
1 << 21, // outside guest permission bitspace
);
let result = room_service.set_settings(room.id, owner.id, settings).await;
assert!(result.is_err(), "Permission escalation should be rejected");
match result.failed("operation should fail") {
Error::InvalidInput(msg) => {
assert!(
msg.contains("guest") && msg.contains("permission bitspace"),
"Error should mention permission escalation: {msg}"
);
}
other => std::panic::panic_any(format!("Expected InvalidInput error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_settings_guest_mode_change_kicks_guests() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let mut event_rx = room_service.notification_service().subscribe();
let owner = user_repo
.create(&make_user("guest_kick_owner"))
.await
.checked("test operation should succeed");
let guest = user_repo
.create(&make_user("guest_kick_guest"))
.await
.checked("test operation should succeed");
let settings = synctv_core::models::RoomSettings {
allow_guest_join: synctv_core::models::room_settings::AllowGuestJoin(true),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Guest Kick Room".to_string(),
String::new(),
owner.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
// Add guest as member with Guest role
let member_service = room_service.member_service();
member_service
.add_member(room.id, guest.id, RoomRole::Guest)
.await
.checked("test operation should succeed");
// Verify guest is a member
assert!(member_repo
.is_member(&room.id, &guest.id)
.await
.checked("test operation should succeed"));
let guest_version_before = room_service
.get_room_guest_version(&room.id)
.await
.checked("test operation should succeed");
// Disable guest join - this should kick the guest
let mut updated_settings = room_service
.get_room_settings(&room.id)
.await
.checked("test operation should succeed");
updated_settings.allow_guest_join = synctv_core::models::room_settings::AllowGuestJoin(false);
let result = room_service
.set_room_settings(&room.id, &updated_settings)
.await;
assert!(
result.is_ok(),
"Setting update should succeed: {:?}",
result.err()
);
let settings = room_service
.get_room_settings(&room.id)
.await
.checked("test operation should succeed");
assert!(
!settings.allow_guest_join.0,
"allow_guest_join should be false"
);
assert!(
!member_repo
.is_member(&room.id, &guest.id)
.await
.checked("test operation should succeed"),
"guest-role members must be removed when room guest mode is disabled"
);
let guest_version_after = room_service
.get_room_guest_version(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(
guest_version_after,
guest_version_before + 1,
"room guest version must be bumped so anonymous guest JWTs are revoked"
);
let (event_room_id, event) = event_rx
.recv()
.await
.checked("test operation should succeed");
assert_eq!(event_room_id, room.id);
match event {
RoomEvent::GuestKicked { reason, .. } => {
assert!(
matches!(reason, GuestKickReason::RoomGuestModeDisabled),
"unexpected guest kick reason: {reason:?}"
);
}
other => std::panic::panic_any(format!("expected GuestKicked event, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_manage_room_settings_emits_settings_updated_notification() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let mut event_rx = room_service.notification_service().subscribe();
let owner = user_repo
.create(&make_user("settings_notify_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Settings Notify Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let updated_settings = RoomSettings {
chat_enabled: synctv_core::models::room_settings::ChatEnabled(false),
allow_guest_join: synctv_core::models::room_settings::AllowGuestJoin(true),
..RoomSettings::default()
};
room_service
.set_room_settings(&room.id, &updated_settings)
.await
.checked("test operation should succeed");
let (event_room_id, event) =
tokio::time::timeout(std::time::Duration::from_secs(1), event_rx.recv())
.await
.checked("expected room settings notification")
.checked("test operation should succeed");
assert_eq!(event_room_id, room.id);
match event {
RoomEvent::SettingsUpdated { settings, .. } => {
assert!(!settings.chat_enabled.0);
assert!(settings.allow_guest_join.0);
}
other => std::panic::panic_any(format!("expected SettingsUpdated event, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_room_settings_with_version_refreshes_local_cache_after_write() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("settings_cache_refresh_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Settings Cache Refresh Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let (initial_settings, initial_version) = room_service
.get_room_settings_with_version(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(initial_version, 1);
assert!(initial_settings.chat_enabled.0);
let updated_settings = RoomSettings {
chat_enabled: synctv_core::models::room_settings::ChatEnabled(false),
..RoomSettings::default()
};
room_service
.set_room_settings(&room.id, &updated_settings)
.await
.checked("test operation should succeed");
let (refreshed_settings, refreshed_version) = room_service
.get_room_settings_with_version(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(refreshed_version, 2);
assert!(
!refreshed_settings.chat_enabled.0,
"local reads must not return a stale cached room settings snapshot after a write"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_settings_mutations_return_committed_snapshots() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("settings_snapshot_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Settings Snapshot Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let updated_settings = RoomSettings {
chat_enabled: synctv_core::models::room_settings::ChatEnabled(false),
allow_guest_join: synctv_core::models::room_settings::AllowGuestJoin(true),
..RoomSettings::default()
};
let snapshot = room_service
.set_settings(room.id, owner.id, updated_settings.clone())
.await
.checked("settings update should return committed snapshot");
assert_eq!(snapshot.version, 2);
assert!(!snapshot.settings.chat_enabled.0);
assert!(snapshot.settings.allow_guest_join.0);
assert_eq!(
snapshot.settings.max_members.0,
updated_settings.max_members.0
);
let admin_updated_settings = RoomSettings {
chat_enabled: synctv_core::models::room_settings::ChatEnabled(true),
allow_guest_join: synctv_core::models::room_settings::AllowGuestJoin(false),
max_members: MaxMembers(50),
..RoomSettings::default()
};
let snapshot = room_service
.set_room_settings(&room.id, &admin_updated_settings)
.await
.checked("admin room settings update should return committed snapshot");
assert_eq!(snapshot.version, 3);
assert!(snapshot.settings.chat_enabled.0);
assert!(!snapshot.settings.allow_guest_join.0);
assert_eq!(
snapshot.settings.max_members.0,
admin_updated_settings.max_members.0
);
let snapshot = room_service
.reset_room_settings(&room.id, &owner.id)
.await
.checked("reset should return committed snapshot");
assert_eq!(snapshot.version, 4);
assert!(snapshot.settings.chat_enabled.0);
assert!(!snapshot.settings.allow_guest_join.0);
assert_eq!(
snapshot.settings.max_members.0,
RoomSettings::default().max_members.0
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_manage_room_settings_disabling_guest_join_kicks_guests() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let mut event_rx = room_service.notification_service().subscribe();
let owner = user_repo
.create(&make_user("full_replace_guest_owner"))
.await
.checked("test operation should succeed");
let guest = user_repo
.create(&make_user("full_replace_guest_member"))
.await
.checked("test operation should succeed");
let initial_settings = RoomSettings {
allow_guest_join: synctv_core::models::room_settings::AllowGuestJoin(true),
..RoomSettings::default()
};
let (room, _) = room_service
.create_room(
"Full Replace Guest Kick Room".to_string(),
String::new(),
owner.id,
None,
Some(initial_settings),
)
.await
.checked("test operation should succeed");
room_service
.member_service()
.add_member(room.id, guest.id, RoomRole::Guest)
.await
.checked("test operation should succeed");
assert!(member_repo
.is_member(&room.id, &guest.id)
.await
.checked("test operation should succeed"));
let updated_settings = RoomSettings {
allow_guest_join: synctv_core::models::room_settings::AllowGuestJoin(false),
..RoomSettings::default()
};
room_service
.set_room_settings(&room.id, &updated_settings)
.await
.checked("test operation should succeed");
let mut guest_kicked = false;
for _ in 0..3 {
let (event_room_id, event) =
tokio::time::timeout(std::time::Duration::from_secs(1), event_rx.recv())
.await
.checked("expected room event after full settings replacement")
.checked("test operation should succeed");
assert_eq!(event_room_id, room.id);
if let RoomEvent::GuestKicked { reason, message } = event {
assert!(matches!(reason, GuestKickReason::RoomGuestModeDisabled));
assert!(
message.to_ascii_lowercase().contains("guest"),
"guest kick message should describe the removal: {message}"
);
guest_kicked = true;
break;
}
}
assert!(guest_kicked, "full settings replacement should kick guests");
assert!(
!member_repo.is_member(&room.id, &guest.id).await.checked("test operation should succeed"),
"guest membership should be revoked after disabling guest join via full settings replacement"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_settings_password_required_triggers_guest_kick() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let mut event_rx = room_service.notification_service().subscribe();
let owner = user_repo
.create(&make_user("pwd_kick_owner"))
.await
.checked("test operation should succeed");
let guest = user_repo
.create(&make_user("pwd_kick_guest"))
.await
.checked("test operation should succeed");
let settings = synctv_core::models::RoomSettings {
allow_guest_join: synctv_core::models::room_settings::AllowGuestJoin(true),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Password Kick Room".to_string(),
String::new(),
owner.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
room_service
.member_service()
.add_member(room.id, guest.id, RoomRole::Guest)
.await
.checked("test operation should succeed");
assert!(member_repo
.is_member(&room.id, &guest.id)
.await
.checked("test operation should succeed"));
let guest_version_before = room_service
.get_room_guest_version(&room.id)
.await
.checked("test operation should succeed");
room_service
.update_room_password(&room.id, Some("NewPassword123".to_string()))
.await
.checked("test operation should succeed");
assert!(
RoomPasswordRepository::new(pool.clone())
.get_state(&room.id)
.await
.checked("test operation should succeed")
.is_some_and(|state| state.enabled),
"room password state should be enabled"
);
assert!(
!member_repo
.is_member(&room.id, &guest.id)
.await
.checked("test operation should succeed"),
"guest-role members must be removed when a room password is added"
);
let guest_version_after = room_service
.get_room_guest_version(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(
guest_version_after,
guest_version_before + 1,
"room guest version must be bumped when adding a password"
);
let (event_room_id, event) = event_rx
.recv()
.await
.checked("test operation should succeed");
assert_eq!(event_room_id, room.id);
match event {
RoomEvent::GuestKicked { reason, .. } => {
assert!(
matches!(reason, GuestKickReason::RoomPasswordAdded),
"unexpected guest kick reason: {reason:?}"
);
}
other => std::panic::panic_any(format!("expected GuestKicked event, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_remove_media_respects_admin_override_columns() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("remove_media_admin_owner"))
.await
.checked("test operation should succeed");
let admin = user_repo
.create(&make_user("remove_media_admin_target"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Admin Remove Media Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.member_service()
.add_member(room.id, admin.id, RoomRole::Admin)
.await
.checked("test operation should succeed");
sqlx::query!(
"UPDATE room_members
SET admin_removed_permissions = admin_removed_permissions | $3,
added_permissions = 0,
removed_permissions = 0
WHERE room_id = $1 AND user_id = $2",
room.id.as_i64(),
admin.id.as_i64(),
u64_to_i64(RoomAdminPermissionBits::DELETE_MEDIA)
)
.execute(&pool)
.await
.checked("test operation should succeed");
let media = media_repo
.create(&make_media(room.id, owner.id, None, "Protected Media"))
.await
.checked("test operation should succeed");
let result = room_service.remove_media(room.id, admin.id, media.id).await;
assert!(
matches!(result, Err(Error::Authorization(_))),
"admin DELETE_MEDIA revoke must be enforced by transactional SQL, got: {result:?}"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_entries_removes_media_and_playlists_in_one_request() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_entries_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Entries Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let top_level_playlist = playlist_repo
.create(&make_playlist(room.id, owner.id, None, "Folder"))
.await
.checked("test operation should succeed");
let root_media = media_repo
.create(&make_media(room.id, owner.id, None, "root-media"))
.await
.checked("test operation should succeed");
let child_media = media_repo
.create(&make_media(
room.id,
owner.id,
Some(top_level_playlist.id),
"child-media",
))
.await
.checked("test operation should succeed");
let result = room_service
.delete_entries(
room.id,
owner.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: vec![top_level_playlist.id],
media_ids: vec![root_media.id],
force: false,
},
)
.await
.checked("test operation should succeed");
assert_eq!(result.deleted_playlists, 1);
assert_eq!(
result.deleted_media, 2,
"deleted_media must include media explicitly targeted plus media removed via playlist cascade"
);
assert_eq!(result.deleted_media_ids.len(), 2);
assert!(result.deleted_media_ids.contains(&root_media.id));
assert!(result.deleted_media_ids.contains(&child_media.id));
assert!(playlist_repo
.get_by_id(&top_level_playlist.id)
.await
.checked("test operation should succeed")
.is_none());
assert!(media_repo
.get_by_id(&root_media.id)
.await
.checked("test operation should succeed")
.is_none());
assert!(media_repo
.get_by_id(&child_media.id)
.await
.checked("test operation should succeed")
.is_none());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_leave_room_removes_owned_resources_before_former_member_can_delete() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_owned_left_owner"))
.await
.checked("test operation should succeed");
let member = user_repo
.create(&make_user("delete_owned_left_member"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Owned Left Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.member_service()
.add_member(room.id, member.id, RoomRole::Admin)
.await
.checked("test operation should succeed");
let media = media_repo
.create(&make_media(room.id, member.id, None, "owned-before-leave"))
.await
.checked("test operation should succeed");
room_service
.leave_room(room.id, member.id)
.await
.checked("test operation should succeed");
assert!(
media_repo
.get_by_id(&media.id)
.await
.checked("test operation should succeed")
.is_none(),
"owned media resource must be cleaned when the member leaves"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_playlist_only_returns_room_root_media() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("root_scope_owner"))
.await
.checked("test operation should succeed");
let (room_a, _) = room_service
.create_room(
"Root Scope A".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let (room_b, _) = room_service
.create_room(
"Root Scope B".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let media_a = media_repo
.create(&make_media(room_a.id, owner.id, None, "room-a-root"))
.await
.checked("test operation should succeed");
media_repo
.create(&make_media(room_b.id, owner.id, None, "room-b-root"))
.await
.checked("test operation should succeed");
let items = room_service
.get_room_root_media(&room_a.id)
.await
.checked("test operation should succeed");
assert_eq!(items.len(), 1, "room root query must stay inside the room");
assert_eq!(items[0].id, media_a.id);
assert_eq!(items[0].room_id, room_a.id);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_entries_allows_foreign_playlist_delete_with_delete_any_permission() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_entries_any_owner"))
.await
.checked("test operation should succeed");
let member = user_repo
.create(&make_user("delete_entries_any_member"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Entries Grant".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let mut admin_member =
synctv_core::models::RoomMember::new(room.id, member.id, RoomRole::Admin);
admin_member.admin_added_permissions = RoomAdminPermissionBits::DELETE_MEDIA;
member_repo
.add(&admin_member)
.await
.checked("test operation should succeed");
let playlist = playlist_repo
.create(&make_playlist(room.id, owner.id, None, "Granted Delete"))
.await
.checked("test operation should succeed");
let result = room_service
.delete_entries(
room.id,
member.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: vec![playlist.id],
media_ids: Vec::new(),
force: false,
},
)
.await
.checked("test operation should succeed");
assert_eq!(result.deleted_playlists, 1);
assert!(playlist_repo
.get_by_id(&playlist.id)
.await
.checked("test operation should succeed")
.is_none());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_entries_denies_foreign_playlist_delete_when_delete_any_revoked() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_entries_revoke_any_owner"))
.await
.checked("test operation should succeed");
let admin = user_repo
.create(&make_user("delete_entries_revoke_any_admin"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Entries Revoke".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.member_service()
.add_member(room.id, admin.id, RoomRole::Admin)
.await
.checked("test operation should succeed");
room_service
.member_service()
.revoke_permission(
room.id,
owner.id,
admin.id,
RoomAdminPermissionBits::DELETE_MEDIA,
)
.await
.checked("test operation should succeed");
let playlist = playlist_repo
.create(&make_playlist(room.id, owner.id, None, "Revoked Delete"))
.await
.checked("test operation should succeed");
let result = room_service
.delete_entries(
room.id,
admin.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: vec![playlist.id],
media_ids: Vec::new(),
force: false,
},
)
.await;
assert!(
matches!(result, Err(Error::Authorization(_))),
"revoked DELETE_MEDIA must prevent foreign playlist deletion, got: {result:?}"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_entries_allows_admin_default_delete_movie_any_for_foreign_media() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_entries_media_owner"))
.await
.checked("test operation should succeed");
let admin = user_repo
.create(&make_user("delete_entries_media_admin"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Entries Media".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.member_service()
.add_member(room.id, admin.id, RoomRole::Admin)
.await
.checked("test operation should succeed");
let media = media_repo
.create(&make_media(room.id, owner.id, None, "foreign-media"))
.await
.checked("test operation should succeed");
let result = room_service
.delete_entries(
room.id,
admin.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: Vec::new(),
media_ids: vec![media.id],
force: false,
},
)
.await
.checked("test operation should succeed");
assert_eq!(result.deleted_media, 1);
assert!(media_repo
.get_by_id(&media.id)
.await
.checked("test operation should succeed")
.is_none());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_entries_notifies_local_media_removed_subscribers() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_entries_notify_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Entries Notify".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let media = media_repo
.create(&make_media(room.id, owner.id, None, "notify-media"))
.await
.checked("test operation should succeed");
let mut event_rx = room_service.notification_service().subscribe();
room_service
.delete_entries(
room.id,
owner.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: Vec::new(),
media_ids: vec![media.id],
force: false,
},
)
.await
.checked("test operation should succeed");
let (event_room_id, event) =
tokio::time::timeout(std::time::Duration::from_secs(1), event_rx.recv())
.await
.checked("expected local notification")
.checked("test operation should succeed");
assert_eq!(event_room_id, room.id);
match event {
RoomEvent::MediaRemoved { media_id, .. } => {
assert_eq!(media_id, media.id);
}
other => std::panic::panic_any(format!("expected MediaRemoved event, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_clear_playlist_notifies_local_media_removed_subscribers() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("clear_playlist_notify_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Clear Playlist Notify".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let media1 = media_repo
.create(&make_media(room.id, owner.id, None, "clear-notify-1"))
.await
.checked("test operation should succeed");
let mut media2_template = make_media(room.id, owner.id, None, "clear-notify-2");
media2_template.position = 1.0;
let media2 = media_repo
.create(&media2_template)
.await
.checked("test operation should succeed");
let mut event_rx = room_service.notification_service().subscribe();
let result = room_service
.clear_playlist(room.id, owner.id, None)
.await
.checked("test operation should succeed");
assert_eq!(result.deleted_count, 2);
let mut removed_ids = std::collections::HashSet::new();
for _ in 0..2 {
let (event_room_id, event) =
tokio::time::timeout(std::time::Duration::from_secs(1), event_rx.recv())
.await
.checked("expected local media removed notification")
.checked("test operation should succeed");
assert_eq!(event_room_id, room.id);
match event {
RoomEvent::MediaRemoved { media_id, .. } => {
removed_ids.insert(media_id);
}
other => std::panic::panic_any(format!("expected MediaRemoved event, got: {other:?}")),
}
}
assert_eq!(
removed_ids,
std::collections::HashSet::from([media1.id, media2.id])
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_clear_playlist_resets_and_invalidates_cached_playback_state_for_room_root_media() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("clear_playlist_playback_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Clear Playlist Playback".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let media = media_repo
.create(&make_media(room.id, owner.id, None, "playing-root-media"))
.await
.checked("test operation should succeed");
room_service
.playback_service()
.switch(room.id, owner.id, Some(media.id), None, None)
.await
.checked("test operation should succeed");
let warm_state = room_service
.playback_service()
.get_state(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(warm_state.playing_media_id, Some(media.id));
let result = room_service
.clear_playlist(room.id, owner.id, None)
.await
.checked("test operation should succeed");
assert_eq!(result.deleted_count, 1);
let refreshed_state = room_service
.playback_service()
.get_state(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(refreshed_state.playing_media_id, None);
assert_eq!(refreshed_state.playing_playlist_id, None);
assert!(!refreshed_state.is_playing);
assert_f64_eq(refreshed_state.position, 0.0);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_clear_playlist_scope_keeps_target_playlist_and_removes_children() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("clear_playlist_scope_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Clear Playlist Scope".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let parent = playlist_repo
.create(&make_playlist(room.id, owner.id, None, "Parent"))
.await
.checked("test operation should succeed");
let child = playlist_repo
.create(&make_playlist(room.id, owner.id, Some(parent.id), "Child"))
.await
.checked("test operation should succeed");
let parent_media = media_repo
.create(&make_media(
room.id,
owner.id,
Some(parent.id),
"parent-media",
))
.await
.checked("test operation should succeed");
let child_media = media_repo
.create(&make_media(
room.id,
owner.id,
Some(child.id),
"child-media",
))
.await
.checked("test operation should succeed");
let result = room_service
.clear_playlist(room.id, owner.id, Some(parent.id))
.await
.checked("test operation should succeed");
assert_eq!(result.deleted_count, 2);
assert_eq!(result.deleted_playlists, 1);
assert_eq!(result.deleted_playlist_ids, vec![child.id]);
assert!(playlist_repo
.get_by_id(&parent.id)
.await
.checked("test operation should succeed")
.is_some());
assert!(playlist_repo
.get_by_id(&child.id)
.await
.checked("test operation should succeed")
.is_none());
assert!(media_repo
.get_by_id(&parent_media.id)
.await
.checked("test operation should succeed")
.is_none());
assert!(media_repo
.get_by_id(&child_media.id)
.await
.checked("test operation should succeed")
.is_none());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_entries_counts_media_deleted_via_playlist_cascade() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_entries_cascade_count_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Entries Cascade Count".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let parent = playlist_repo
.create(&make_playlist(room.id, owner.id, None, "Parent"))
.await
.checked("test operation should succeed");
let child = playlist_repo
.create(&make_playlist(room.id, owner.id, Some(parent.id), "Child"))
.await
.checked("test operation should succeed");
let parent_media = media_repo
.create(&make_media(
room.id,
owner.id,
Some(parent.id),
"parent-media",
))
.await
.checked("test operation should succeed");
let child_media = media_repo
.create(&make_media(
room.id,
owner.id,
Some(child.id),
"child-media",
))
.await
.checked("test operation should succeed");
let result = room_service
.delete_entries(
room.id,
owner.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: vec![parent.id],
media_ids: Vec::new(),
force: false,
},
)
.await
.checked("test operation should succeed");
assert_eq!(
result.deleted_playlists, 2,
"delete_entries must count the full deleted playlist subtree"
);
assert_eq!(
result.deleted_media, 2,
"delete_entries must count media removed through playlist cascade"
);
assert!(media_repo
.get_by_id(&parent_media.id)
.await
.checked("test operation should succeed")
.is_none());
assert!(media_repo
.get_by_id(&child_media.id)
.await
.checked("test operation should succeed")
.is_none());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_entries_notifies_local_media_removed_for_playlist_cascade() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_entries_cascade_notify_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Entries Cascade Notify".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let playlist = playlist_repo
.create(&make_playlist(room.id, owner.id, None, "Cascade Playlist"))
.await
.checked("test operation should succeed");
let media = media_repo
.create(&make_media(
room.id,
owner.id,
Some(playlist.id),
"cascade-media",
))
.await
.checked("test operation should succeed");
let mut event_rx = room_service.notification_service().subscribe();
room_service
.delete_entries(
room.id,
owner.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: vec![playlist.id],
media_ids: Vec::new(),
force: false,
},
)
.await
.checked("test operation should succeed");
let (event_room_id, event) =
tokio::time::timeout(std::time::Duration::from_secs(1), event_rx.recv())
.await
.checked("expected local notification for cascade delete")
.checked("test operation should succeed");
assert_eq!(event_room_id, room.id);
match event {
RoomEvent::MediaRemoved { media_id, .. } => {
assert_eq!(media_id, media.id);
}
other => std::panic::panic_any(format!("expected MediaRemoved event, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_entries_notifies_local_playlist_deleted_subscribers() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_entries_playlist_notify_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Entries Playlist Notify".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let playlist = playlist_repo
.create(&make_playlist(room.id, owner.id, None, "notify-playlist"))
.await
.checked("test operation should succeed");
let mut event_rx = room_service.notification_service().subscribe();
room_service
.delete_entries(
room.id,
owner.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: vec![playlist.id],
media_ids: Vec::new(),
force: false,
},
)
.await
.checked("test operation should succeed");
let (event_room_id, event) =
tokio::time::timeout(std::time::Duration::from_secs(1), event_rx.recv())
.await
.checked("expected local playlist delete notification")
.checked("test operation should succeed");
assert_eq!(event_room_id, room.id);
match event {
RoomEvent::PlaylistDeleted { playlist_id, .. } => {
assert_eq!(playlist_id, playlist.id);
}
other => std::panic::panic_any(format!("expected PlaylistDeleted event, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_entries_rejects_currently_playing_resources_without_force() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_entries_playing_media_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Entries Playing Media".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let media = media_repo
.create(&make_media(room.id, owner.id, None, "playing-media"))
.await
.checked("test operation should succeed");
room_service
.playback_service()
.switch(room.id, owner.id, Some(media.id), None, None)
.await
.checked("test operation should succeed");
let result = room_service
.delete_entries(
room.id,
owner.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: Vec::new(),
media_ids: vec![media.id],
force: false,
},
)
.await;
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"deleting currently playing media without force must be rejected"
);
assert!(media_repo
.get_by_id(&media.id)
.await
.checked("test operation should succeed")
.is_some());
let (playlist_room, _) = room_service
.create_room(
"Delete Entries Playing Playlist".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let parent = playlist_repo
.create(&make_playlist(playlist_room.id, owner.id, None, "parent"))
.await
.checked("test operation should succeed");
let child = playlist_repo
.create(&make_playlist(
playlist_room.id,
owner.id,
Some(parent.id),
"child",
))
.await
.checked("test operation should succeed");
let child_media = media_repo
.create(&make_media(
playlist_room.id,
owner.id,
Some(child.id),
"deep-playing-media",
))
.await
.checked("test operation should succeed");
room_service
.playback_service()
.switch(playlist_room.id, owner.id, Some(child_media.id), None, None)
.await
.checked("test operation should succeed");
let result = room_service
.delete_entries(
playlist_room.id,
owner.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: vec![parent.id],
media_ids: Vec::new(),
force: false,
},
)
.await;
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"deleting an ancestor playlist of the currently playing media without force must be rejected"
);
assert!(playlist_repo
.get_by_id(&parent.id)
.await
.checked("test operation should succeed")
.is_some());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_entries_force_clears_playback_state_and_deletes_playing_resources() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_entries_force_media_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Entries Force Media".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let media = media_repo
.create(&make_media(room.id, owner.id, None, "force-playing-media"))
.await
.checked("test operation should succeed");
room_service
.playback_service()
.switch(room.id, owner.id, Some(media.id), None, None)
.await
.checked("test operation should succeed");
let result = room_service
.delete_entries(
room.id,
owner.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: Vec::new(),
media_ids: vec![media.id],
force: true,
},
)
.await
.checked("test operation should succeed");
assert_eq!(result.deleted_media, 1);
assert!(media_repo
.get_by_id(&media.id)
.await
.checked("test operation should succeed")
.is_none());
let state = room_service
.playback_service()
.get_state(&room.id)
.await
.checked("test operation should succeed");
assert!(state.playing_media_id.is_none());
assert!(state.playing_playlist_id.is_none());
assert!(!state.is_playing);
assert_f64_eq(state.position, 0.0);
let (playlist_room, _) = room_service
.create_room(
"Delete Entries Force Playlist".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let parent = playlist_repo
.create(&make_playlist(
playlist_room.id,
owner.id,
None,
"force-parent",
))
.await
.checked("test operation should succeed");
let child = playlist_repo
.create(&make_playlist(
playlist_room.id,
owner.id,
Some(parent.id),
"force-child",
))
.await
.checked("test operation should succeed");
let media = media_repo
.create(&make_media(
playlist_room.id,
owner.id,
Some(child.id),
"force-deep-playing-media",
))
.await
.checked("test operation should succeed");
room_service
.playback_service()
.switch(playlist_room.id, owner.id, Some(media.id), None, None)
.await
.checked("test operation should succeed");
let result = room_service
.delete_entries(
playlist_room.id,
owner.id,
synctv_core::service::DeleteEntriesRequest {
playlist_ids: vec![parent.id],
media_ids: Vec::new(),
force: true,
},
)
.await
.checked("test operation should succeed");
assert_eq!(
result.deleted_playlists, 2,
"force deleting an ancestor playlist must count descendants too"
);
assert_eq!(result.deleted_media, 1);
assert!(playlist_repo
.get_by_id(&parent.id)
.await
.checked("test operation should succeed")
.is_none());
assert!(playlist_repo
.get_by_id(&child.id)
.await
.checked("test operation should succeed")
.is_none());
assert!(media_repo
.get_by_id(&media.id)
.await
.checked("test operation should succeed")
.is_none());
let state = room_service
.playback_service()
.get_state(&playlist_room.id)
.await
.checked("test operation should succeed");
assert!(state.playing_media_id.is_none());
assert!(state.playing_playlist_id.is_none());
assert!(!state.is_playing);
assert_f64_eq(state.position, 0.0);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_create_room_preserves_supported_name_and_description_text() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("unicode_name_owner"))
.await
.checked("test operation should succeed");
let unicode_name = "Room \u{4e2d}\u{6587} \u{65e5}\u{672c}\u{8a9e} \u{c0}\u{e9}\u{f1}";
let (unicode_room, _) = room_service
.create_room(
unicode_name.to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
assert_eq!(unicode_room.name, unicode_name);
let name_with_spaces = " Room with spaces ";
let (spaced_room, _) = room_service
.create_room(
name_with_spaces.to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
assert_eq!(spaced_room.name, name_with_spaces);
let description = "Line 1\nLine 2\nLine 3\n\nParagraph 2";
let (description_room, _) = room_service
.create_room(
"Newline Room".to_string(),
description.to_string(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
assert_eq!(description_room.description, description);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_cannot_join_closed_room() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("closed_owner"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("closed_joiner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Closed Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.update_room_status(&room.id, synctv_core::models::RoomStatus::Closed)
.await
.checked("test operation should succeed");
let result = room_service.join_room(room.id, joiner.id, None).await;
assert!(result.is_err(), "Should not be able to join closed room");
match result.failed("operation should fail") {
Error::InvalidInput(msg) => {
assert!(
msg.contains("closed"),
"Error should mention room is closed: {msg}"
);
}
other => std::panic::panic_any(format!("Expected InvalidInput error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_creation_creates_all_related_records_atomically() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("atomic_owner"))
.await
.checked("test operation should succeed");
let (room, _member) = room_service
.create_room(
"Atomic Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let room_repo = RoomRepository::new(pool.clone());
assert!(room_repo
.exists(&room.id)
.await
.checked("test operation should succeed"));
let member_repo = RoomMemberRepository::new(pool.clone());
let creator_membership = member_repo
.get(&room.id, &owner.id)
.await
.checked("test operation should succeed");
assert!(creator_membership.is_some(), "Creator should be a member");
assert_eq!(
creator_membership
.checked("test operation should succeed")
.role,
RoomRole::Creator
);
let settings_repo = RoomSettingsRepository::new(pool.clone());
let settings = settings_repo
.get(&room.id)
.await
.checked("test operation should succeed");
assert!(settings.chat_enabled.0, "Chat should be enabled by default");
let playlist_count: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM playlists WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(
playlist_count, 0,
"Room creation should not create playlist rows"
);
let playback_count: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM room_playback_state WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(playback_count, 1, "Playback state should exist");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_non_creator_cannot_delete_room() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("del_owner"))
.await
.checked("test operation should succeed");
let other_user = user_repo
.create(&make_user("del_other"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Non-Creator Delete Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let result = room_service.delete_room(room.id, other_user.id).await;
assert!(
result.is_err(),
"Non-creator should not be able to delete room"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_admin_can_delete_room_with_permission() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("room_admin_del_owner"))
.await
.checked("test operation should succeed");
let room_admin = user_repo
.create(&make_user("room_admin_del_actor"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Room Admin Delete Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.member_service()
.add_member(room.id, room_admin.id, RoomRole::Admin)
.await
.checked("test operation should succeed");
room_service
.member_service()
.grant_permission(
room.id,
owner.id,
room_admin.id,
RoomAdminPermissionBits::DELETE_ROOM,
)
.await
.checked("test operation should succeed");
room_service
.delete_room(room.id, room_admin.id)
.await
.checked("test operation should succeed");
let room_repo = RoomRepository::new(pool.clone());
let fetched = room_repo
.get_by_id(&room.id)
.await
.checked("test operation should succeed");
assert!(
fetched.is_none(),
"Room admin with delete_room should delete room"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_admin_without_delete_room_cannot_delete_room() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("room_admin_no_del_owner"))
.await
.checked("test operation should succeed");
let room_admin = user_repo
.create(&make_user("room_admin_no_del_actor"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Room Admin No Delete Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
member_repo
.add(&synctv_core::models::RoomMember::new(
room.id,
room_admin.id,
RoomRole::Admin,
))
.await
.checked("test operation should succeed");
let result = room_service.delete_room(room.id, room_admin.id).await;
assert!(
result.is_err(),
"Room admin without delete_room should not delete room"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_global_admin_can_delete_room_via_delete_room() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("global_admin_del_owner"))
.await
.checked("test operation should succeed");
let mut admin_user = user_repo
.create(&make_user("global_admin_del_actor"))
.await
.checked("test operation should succeed");
admin_user.role = UserRole::Admin;
user_repo
.update(&admin_user, admin_user.version)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Global Admin Delete Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.delete_room(room.id, admin_user.id)
.await
.checked("test operation should succeed");
let fetched = room_repo
.get_by_id(&room.id)
.await
.checked("test operation should succeed");
assert!(
fetched.is_none(),
"Room should be soft-deleted by global admin"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_admin_delete_room_bypasses_permission_check() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("admin_del_owner"))
.await
.checked("test operation should succeed");
let mut admin_user = user_repo
.create(&make_user("admin_del_admin"))
.await
.checked("test operation should succeed");
admin_user.role = UserRole::Admin;
user_repo
.update(&admin_user, admin_user.version)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Admin Delete Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.admin_delete_room(&room.id, &admin_user.id)
.await
.checked("test operation should succeed");
let fetched = room_repo
.get_by_id(&room.id)
.await
.checked("test operation should succeed");
assert!(fetched.is_none(), "Room should be soft-deleted");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_admin_delete_room_requires_admin_or_root_role() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room("Test Room".to_string(), String::new(), owner.id, None, None)
.await
.checked("test operation should succeed");
let regular_user = user_repo
.create(&make_user("regular_user"))
.await
.checked("test operation should succeed");
let result = room_service
.admin_delete_room(&room.id, &regular_user.id)
.await;
assert!(
result.is_err(),
"Non-admin user should not be able to call admin_delete_room"
);
if let Err(Error::Authorization(msg)) = result {
assert!(
msg.contains("admin") || msg.contains("Admin"),
"Error message should mention admin requirement"
);
} else {
std::panic::panic_any(format!("Expected Authorization error, got {result:?}"));
}
let fetched = room_repo
.get_by_id(&room.id)
.await
.checked("test operation should succeed");
assert!(
fetched.is_some(),
"Room should still exist after failed admin delete"
);
let mut admin_user = user_repo
.create(&make_user("admin_user"))
.await
.checked("test operation should succeed");
admin_user.role = UserRole::Admin;
user_repo
.update(&admin_user, admin_user.version)
.await
.checked("test operation should succeed");
room_service
.admin_delete_room(&room.id, &admin_user.id)
.await
.checked("test operation should succeed");
let fetched = room_repo
.get_by_id(&room.id)
.await
.checked("test operation should succeed");
assert!(fetched.is_none(), "Room should be soft-deleted by admin");
let owner = user_repo
.create(&make_user("owner2"))
.await
.checked("test operation should succeed");
let (root_room, _) = room_service
.create_room(
"Test Room 2".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let mut root_user = user_repo
.create(&make_user("root_user"))
.await
.checked("test operation should succeed");
root_user.role = UserRole::Root;
user_repo
.update(&root_user, root_user.version)
.await
.checked("test operation should succeed");
room_service
.admin_delete_room(&root_room.id, &root_user.id)
.await
.checked("test operation should succeed");
let fetched = room_repo
.get_by_id(&root_room.id)
.await
.checked("test operation should succeed");
assert!(fetched.is_none(), "Room should be soft-deleted by root");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_nonexistent_room_returns_error() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("delete_nonexistent_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"To Be Deleted".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.delete_room(room.id, owner.id)
.await
.checked("test operation should succeed");
let result = room_service.delete_room(room.id, owner.id).await;
assert!(result.is_err(), "Deleting already-deleted room should fail");
match result.failed("operation should fail") {
Error::NotFound(msg) => {
assert!(
msg.contains("not found") || msg.contains("deleted"),
"Error should mention not found: {msg}"
);
}
other => std::panic::panic_any(format!("Expected NotFound error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_member_count_batch_efficient_query() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("batch_owner"))
.await
.checked("test operation should succeed");
let mut room_ids = Vec::new();
for i in 0..5 {
let (room, _) = room_service
.create_room(
format!("Batch Room {i}"),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_ids.push(room.id);
}
let room_id_refs: Vec<_> = room_ids.iter().collect();
let counts = room_service
.get_member_count_batch(&room_id_refs)
.await
.checked("test operation should succeed");
for room_id in &room_ids {
assert_eq!(
counts.get(room_id).unwrap_or(&0),
&1,
"Each room should have 1 member"
);
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_exists_is_efficient() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("exists_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Exists Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
assert!(room_service
.room_exists(&room.id)
.await
.checked("test operation should succeed"));
let missing_room_id = synctv_core::models::RoomId::new();
assert!(!room_service
.room_exists(&missing_room_id)
.await
.checked("test operation should succeed"));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_list_rooms_by_creator() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("list_owner"))
.await
.checked("test operation should succeed");
let other = user_repo
.create(&make_user("list_other"))
.await
.checked("test operation should succeed");
for i in 0..3 {
room_service
.create_room(
format!("Owner Room {i}"),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
}
for i in 0..2 {
room_service
.create_room(
format!("Other Room {i}"),
String::new(),
other.id,
None,
None,
)
.await
.checked("test operation should succeed");
}
let (rooms, total) = room_service
.list_rooms_by_creator(&owner.id, synctv_core::models::PageParams::default())
.await
.checked("test operation should succeed");
assert_eq!(total, 3, "Owner should have 3 rooms");
assert_eq!(rooms.len(), 3, "Should return all 3 rooms");
for room in &rooms {
assert_eq!(room.created_by, owner.id, "Room should be created by owner");
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_list_accessible_rooms_excludes_rooms_with_inactive_creator() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let active_owner = user_repo
.create(&make_user("accessible_owner"))
.await
.checked("test operation should succeed");
let inactive_owner = user_repo
.create(&make_user("inaccessible_owner"))
.await
.checked("test operation should succeed");
let (visible_room, _) = room_service
.create_room(
"Visible Room".to_string(),
String::new(),
active_owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.create_room(
"Hidden Room".to_string(),
String::new(),
inactive_owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.user_service()
.ban_user(&inactive_owner.id, None, None)
.await
.checked("test operation should succeed");
let (rooms, total) = room_service
.list_accessible_rooms(&RoomListQuery {
pagination: PageParams::default(),
status: Some(RoomStatus::Active),
is_banned: Some(false),
..Default::default()
})
.await
.checked("test operation should succeed");
assert_eq!(
total, 1,
"only rooms with active creators should be counted"
);
assert_eq!(
rooms.len(),
1,
"only rooms with active creators should be listed"
);
assert_eq!(rooms[0].id, visible_room.id);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_list_accessible_joined_rooms_retains_rooms_with_banned_creator() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let active_owner = user_repo
.create(&make_user("joined_visible_owner"))
.await
.checked("test operation should succeed");
let inactive_owner = user_repo
.create(&make_user("joined_hidden_owner"))
.await
.checked("test operation should succeed");
let member = user_repo
.create(&make_user("joined_member"))
.await
.checked("test operation should succeed");
let (visible_room, _) = room_service
.create_room(
"Joined Visible Room".to_string(),
String::new(),
active_owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let (hidden_room, _) = room_service
.create_room(
"Joined Hidden Room".to_string(),
String::new(),
inactive_owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(visible_room.id, member.id, None)
.await
.checked("test operation should succeed");
room_service
.join_room(hidden_room.id, member.id, None)
.await
.checked("test operation should succeed");
room_service
.user_service()
.ban_user(&inactive_owner.id, None, None)
.await
.checked("test operation should succeed");
let (rooms, total) = room_service
.list_accessible_joined_rooms_with_query_eventually_consistent(
&member.id,
&MyRoomListQuery {
pagination: PageParams::default(),
..Default::default()
},
)
.await
.checked("test operation should succeed");
assert_eq!(total, 2, "both joined rooms should be counted");
assert_eq!(rooms.len(), 2, "both joined rooms should be listed");
assert!(rooms.iter().any(|entry| entry.0.id == visible_room.id));
assert!(rooms.iter().any(|entry| entry.0.id == hidden_room.id));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_list_rooms_pagination() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
persist_test_runtime_settings(&registry, |settings| {
settings.room_creation.max_rooms_per_user = 32;
})
.await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let owner = user_repo
.create(&make_user("page_owner"))
.await
.checked("test operation should succeed");
for i in 0..15 {
room_service
.create_room(
format!("Page Room {i:02}"),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
}
let page1 = synctv_core::models::PageParams {
page: 1,
page_size: 10,
};
let (rooms, total) = room_service
.list_rooms_by_creator(&owner.id, page1)
.await
.checked("test operation should succeed");
assert_eq!(total, 15, "Total should be 15");
assert_eq!(rooms.len(), 10, "First page should have 10 rooms");
let page2 = synctv_core::models::PageParams {
page: 2,
page_size: 10,
};
let (rooms2, total2) = room_service
.list_rooms_by_creator(&owner.id, page2)
.await
.checked("test operation should succeed");
assert_eq!(total2, 15, "Total should still be 15");
assert_eq!(rooms2.len(), 5, "Second page should have 5 rooms");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_guest_cannot_join_password_protected_room() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("guest_pwd_owner"))
.await
.checked("test operation should succeed");
let settings = synctv_core::models::RoomSettings {
allow_guest_join: synctv_core::models::room_settings::AllowGuestJoin(true),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Guest Password Room".to_string(),
String::new(),
owner.id,
Some("SecretPassword123".to_string()),
Some(settings),
)
.await
.checked("test operation should succeed");
let result = room_service.check_guest_allowed(&room.id, None).await;
assert!(
result.is_err(),
"Guests should not be able to join password-protected room"
);
match result.failed("operation should fail") {
Error::Authorization(msg) => {
assert!(
msg.contains("password") || msg.contains("Guest"),
"Error should mention password or guests: {msg}"
);
}
other => std::panic::panic_any(format!("Expected Authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_check_guest_allowed_when_disabled_globally() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("guest_disabled_owner"))
.await
.checked("test operation should succeed");
let settings = synctv_core::models::RoomSettings {
allow_guest_join: synctv_core::models::room_settings::AllowGuestJoin(true),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Guest Disabled Room".to_string(),
String::new(),
owner.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
let result = room_service.check_guest_allowed(&room.id, None).await;
assert!(
result.is_err(),
"Should deny guests when registry unavailable"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_room_description_enforces_permissions_and_length() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("desc_update_owner"))
.await
.checked("test operation should succeed");
let outsider = user_repo
.create(&make_user("desc_perm_outsider"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Description Update Room".to_string(),
"Original description".to_string(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
assert_eq!(room.description, "Original description");
let new_description = "Updated description with more details";
let updated_room = room_service
.update_room_description(&room.id, &owner.id, new_description.to_string())
.await
.checked("test operation should succeed");
assert_eq!(updated_room.description, new_description);
let long_description = "x".repeat(501);
let result = room_service
.update_room_description(&room.id, &owner.id, long_description)
.await;
assert!(result.is_err(), "Description > 500 chars should fail");
let result = room_service
.update_room_description(&room.id, &outsider.id, "Hacked description".to_string())
.await;
assert!(
result.is_err(),
"Non-member should not be able to update description"
);
let err = result.failed("operation should fail");
let err_str = err.to_string();
assert!(
err_str.contains("permission")
|| err_str.contains("denied")
|| err_str.contains("not found")
|| err_str.contains("Not a member"),
"Error should indicate permission denied or not a member: {err_str}"
);
let room_after = room_service
.get_room(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(room_after.description, new_description);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_room_visibility_revokes_guest_access_and_enforces_permissions() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("visibility_owner"))
.await
.checked("test operation should succeed");
let outsider = user_repo
.create(&make_user("visibility_outsider"))
.await
.checked("test operation should succeed");
let guest = user_repo
.create(&make_user("visibility_guest"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Visibility Room".to_string(),
String::new(),
owner.id,
None,
Some(RoomSettings {
allow_guest_join: synctv_core::models::room_settings::AllowGuestJoin(true),
..Default::default()
}),
)
.await
.checked("test operation should succeed");
assert!(room.is_public);
room_service
.member_service()
.add_member(room.id, guest.id, RoomRole::Guest)
.await
.checked("test operation should succeed");
let guest_version_before = room_service
.get_room_guest_version(&room.id)
.await
.checked("test operation should succeed");
let mut event_rx = room_service.notification_service().subscribe();
let updated = room_service
.update_room_visibility(&room.id, &owner.id, false)
.await
.checked("owner should be able to make the room private");
assert!(!updated.is_public);
assert_eq!(updated.version, room.version + 1);
let persisted = room_service
.get_room(&room.id)
.await
.checked("updated room should load");
assert!(!persisted.is_public);
assert!(matches!(
room_service.ensure_guest_room_available(&persisted).await,
Err(Error::Authorization(_))
));
assert!(
!member_repo
.is_member(&room.id, &guest.id)
.await
.checked("test operation should succeed"),
"guest-role members must be removed when the room becomes private"
);
assert_eq!(
room_service
.get_room_guest_version(&room.id)
.await
.checked("test operation should succeed"),
guest_version_before + 1
);
let (public_rooms, public_total) = room_service
.list_rooms(&RoomListQuery {
creator_id: Some(owner.id),
is_public: Some(true),
..Default::default()
})
.await
.checked("public room query should succeed");
assert_eq!(public_total, 0);
assert!(public_rooms.is_empty());
let (private_rooms, private_total) = room_service
.list_rooms(&RoomListQuery {
creator_id: Some(owner.id),
is_public: Some(false),
..Default::default()
})
.await
.checked("private room query should succeed");
assert_eq!(private_total, 1);
assert_eq!(private_rooms[0].id, room.id);
room_service
.join_room(room.id, outsider.id, None)
.await
.checked("authenticated users with the room ID should still be able to join");
let (event_room_id, event) = event_rx
.recv()
.await
.checked("guest kick event should be emitted");
assert_eq!(event_room_id, room.id);
assert!(matches!(
event,
RoomEvent::GuestKicked {
reason: GuestKickReason::RoomMadePrivate,
..
}
));
assert!(
room_service
.update_room_visibility(&room.id, &outsider.id, true)
.await
.is_err(),
"users without room settings permission must not change visibility"
);
let system_updated = room_service
.admin_update_room_visibility(&room.id, true)
.await
.checked("authenticated management plane should change visibility");
assert!(system_updated.is_public);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_join_room_idempotent_same_user() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("idem_owner"))
.await
.checked("test operation should succeed");
let joiner = user_repo
.create(&make_user("idem_joiner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Idempotent Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let result1 = room_service.join_room(room.id, joiner.id, None).await;
assert!(result1.is_ok(), "First join should succeed");
let count1 = member_repo
.count_by_room(&room.id)
.await
.checked("test operation should succeed");
let result2 = room_service.join_room(room.id, joiner.id, None).await;
assert!(result2.is_ok(), "Second join should succeed (idempotent)");
let count2 = member_repo
.count_by_room(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(
count1, count2,
"Member count should not increase on idempotent join"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_create_room_persists_default_max_members_setting() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let settings_repo = RoomSettingsRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("settings_owner"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Settings Test Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let settings = settings_repo
.get(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(
settings.max_members.0, 100,
"Default max_members should be 100"
);
}
/// Test that concurrent joins cannot exceed `max_members` limit.
///
/// This test spawns multiple concurrent join requests and verifies that
/// even under concurrent access, the room never exceeds its `max_members` limit.
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_concurrent_joins_cannot_exceed_max_members() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = Arc::new(make_room_service(pool.clone()));
let member_repo = RoomMemberRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("concurrent_owner"))
.await
.checked("test operation should succeed");
let settings = synctv_core::models::RoomSettings {
max_members: synctv_core::models::room_settings::MaxMembers(5),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Concurrent Room".to_string(),
String::new(),
owner.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
let mut users = Vec::new();
for i in 0..20 {
let user = user_repo
.create(&make_user(&format!("concurrent_joiner_{i}")))
.await
.checked("test operation should succeed");
users.push(user);
}
// Track success/failure counts (wrapped in Arc for sharing across tasks)
let success_count = Arc::new(AtomicUsize::new(0));
let failure_count = Arc::new(AtomicUsize::new(0));
// Spawn all join requests concurrently
let mut handles = Vec::new();
for user in users {
let room_service = room_service.clone();
let room_id = room.id;
let success_count = Arc::clone(&success_count);
let failure_count = Arc::clone(&failure_count);
let handle = tokio::spawn(async move {
let result = room_service.join_room(room_id, user.id, None).await;
match result {
Ok(_) => {
success_count.fetch_add(1, Ordering::SeqCst);
}
Err(Error::InvalidInput(_)) => {
// Expected for users who couldn't join due to capacity
failure_count.fetch_add(1, Ordering::SeqCst);
}
Err(Error::AlreadyExists(_)) => {
// Idempotent join - treat as success but don't increment count
// (shouldn't happen in this test since all users are unique)
}
Err(e) => {
std::panic::panic_any(format!("Unexpected error type: {e:?}"));
}
}
});
handles.push(handle);
}
for handle in handles {
handle.await.checked("Join task panicked");
}
let final_count = member_repo
.count_by_room(&room.id)
.await
.checked("test operation should succeed");
assert_eq!(
final_count, 5,
"Room should have exactly 5 members (max limit)"
);
let successes = success_count.load(Ordering::SeqCst);
let failures = failure_count.load(Ordering::SeqCst);
assert_eq!(
successes, 4,
"Exactly 4 users should have joined successfully"
);
assert_eq!(
failures, 16,
"16 users should have been rejected due to capacity"
);
assert_eq!(
successes + failures,
20,
"All 20 users should have been processed"
);
}
/// Soft-delete retains the recoverable aggregate and removes volatile room data.
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_soft_delete_retains_recoverable_data_and_cleans_up_volatile_data() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("cleanup_owner"))
.await
.checked("test operation should succeed");
let member1 = user_repo
.create(&make_user("cleanup_member1"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Cleanup Test Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, member1.id, None)
.await
.checked("test operation should succeed");
let playlist_repo = synctv_core::repository::PlaylistRepository::new(pool.clone());
let media_repo = synctv_core::repository::MediaRepository::new(pool.clone());
let playlist = playlist_repo
.create(&make_playlist(room.id, owner.id, None, "Test Playlist"))
.await
.checked("test operation should succeed");
media_repo
.create(&make_media(
room.id,
owner.id,
Some(playlist.id),
"Test Media",
))
.await
.checked("test operation should succeed");
let member_count_before: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM room_members WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(
member_count_before, 2,
"Owner and member1 should be in room"
);
let playlist_count_before: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM playlists WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert!(playlist_count_before > 0, "Should have playlists");
let media_count_before: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM media WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert!(media_count_before > 0, "Should have media");
let settings_count_before: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM room_settings WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert!(settings_count_before > 0, "Should have settings");
let playback_count_before: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM room_playback_state WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(playback_count_before, 1, "Should have playback state");
let chat_count_before: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM chat_messages WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert!(chat_count_before > 0, "Should have system chat messages");
room_service
.delete_room(room.id, owner.id)
.await
.checked("test operation should succeed");
let deleted_at = sqlx::query_scalar!(
"SELECT deleted_at FROM rooms WHERE id = $1",
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert!(deleted_at.is_some(), "Room should be soft-deleted");
let member_count_after: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM room_members WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(
member_count_after, 0,
"Members should be immediately cleaned up"
);
let playlist_count_after: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM playlists WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(
playlist_count_after, playlist_count_before,
"Playlists should remain available for room recovery"
);
let deleted_playlist_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM playlists WHERE room_id = $1 AND deleted_at IS NOT NULL AND deletion_source = $2",
)
.bind(room.id.as_i64())
.bind(DeletionSource::Room)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(deleted_playlist_count, playlist_count_before);
let media_count_after: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM media WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(
media_count_after, media_count_before,
"Media should remain available for room recovery"
);
let deleted_media_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM media WHERE room_id = $1 AND deleted_at IS NOT NULL AND deletion_source = $2",
)
.bind(room.id.as_i64())
.bind(DeletionSource::Room)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(deleted_media_count, media_count_before);
let settings_count_after: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM room_settings WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(
settings_count_after, settings_count_before,
"Settings should remain available for room recovery"
);
let playback_count_after: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM room_playback_state WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(
playback_count_after, 0,
"Playback state should be immediately cleaned up"
);
let chat_count_after: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM chat_messages WHERE room_id = $1"#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(
chat_count_after, chat_count_before,
"Chat messages should remain available for room recovery"
);
let deleted_chat_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM chat_messages WHERE room_id = $1 AND deleted_at IS NOT NULL AND deletion_source = $2",
)
.bind(room.id.as_i64())
.bind(DeletionSource::Room)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(deleted_chat_count, chat_count_before);
let room_exists = sqlx::query_scalar!(
r#"SELECT EXISTS(SELECT 1 FROM rooms WHERE id = $1) AS "exists!""#,
room.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert!(room_exists, "Room row should still exist (soft-deleted)");
// does not have an audit service configured. Audit functionality is
// tested separately in audit service tests.
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_admin_delete_orphaned_room_removes_rooms_with_inactive_creators() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let creator = user_repo
.create(&make_user("orphan_creator_1"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Orphaned Room 1".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let admin = user_repo
.create(&make_user("admin_orphan_1"))
.await
.checked("test operation should succeed");
let mut admin = admin;
admin.role = UserRole::Admin;
let admin = user_repo
.update(&admin, 0)
.await
.checked("test operation should succeed");
sqlx::query!(
"UPDATE users SET deleted_at = NOW() WHERE id = $1",
creator.id.as_i64()
)
.execute(&pool)
.await
.checked("test operation should succeed");
let deleted_creator = user_repo
.get_by_id(&creator.id)
.await
.checked("test operation should succeed");
assert!(
deleted_creator.is_none(),
"Soft-deleted user should not be found"
);
room_service
.admin_delete_orphaned_room(&room.id, &admin.id)
.await
.checked("test operation should succeed");
let fetched = room_repo
.get_by_id(&room.id)
.await
.checked("test operation should succeed");
assert!(
fetched.is_none(),
"Orphaned room should be soft-deleted by admin"
);
let banned_creator = user_repo
.create(&make_user("banned_creator"))
.await
.checked("test operation should succeed");
let (banned_room, _) = room_service
.create_room(
"Banned Creator Room".to_string(),
String::new(),
banned_creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
user_repo
.ban(
&banned_creator.id,
Some(&admin.id),
Some("creator banned by admin".to_string()),
)
.await
.checked("test operation should succeed");
room_service
.admin_delete_orphaned_room(&banned_room.id, &admin.id)
.await
.checked("test operation should succeed");
let fetched = room_repo
.get_by_id(&banned_room.id)
.await
.checked("test operation should succeed");
assert!(
fetched.is_none(),
"Orphaned room with banned creator should be soft-deleted"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_admin_delete_orphaned_room_rejects_invalid_requests() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let mut admin = user_repo
.create(&make_user("admin_orphan_invalid"))
.await
.checked("test operation should succeed");
admin.role = UserRole::Admin;
let admin = user_repo
.update(&admin, admin.version)
.await
.checked("test operation should succeed");
let creator = user_repo
.create(&make_user("active_creator"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Active Creator Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let result = room_service
.admin_delete_orphaned_room(&room.id, &admin.id)
.await;
assert!(
result.is_err(),
"Should reject orphaned deletion for active creator"
);
let fetched = room_repo
.get_by_id(&room.id)
.await
.checked("test operation should succeed");
assert!(
fetched.is_some(),
"Room should still exist when creator is active"
);
let orphan_creator = user_repo
.create(&make_user("orphan_creator_non_admin"))
.await
.checked("test operation should succeed");
let (orphan_room, _) = room_service
.create_room(
"Orphaned Room Non Admin".to_string(),
String::new(),
orphan_creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let regular_user = user_repo
.create(&make_user("regular_orphan_delete"))
.await
.checked("test operation should succeed");
sqlx::query!(
"UPDATE users SET deleted_at = NOW() WHERE id = $1",
orphan_creator.id.as_i64()
)
.execute(&pool)
.await
.checked("test operation should succeed");
let result = room_service
.admin_delete_orphaned_room(&orphan_room.id, &regular_user.id)
.await;
assert!(
matches!(result, Err(Error::Authorization(_))),
"Non-admin user must not be able to delete orphaned rooms"
);
assert!(room_repo
.get_by_id(&orphan_room.id)
.await
.checked("test operation should succeed")
.is_some());
let creator = user_repo
.create(&make_user("orphan_already_del"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Already Deleted Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
sqlx::query!(
"UPDATE users SET deleted_at = NOW() WHERE id = $1",
creator.id.as_i64()
)
.execute(&pool)
.await
.checked("test operation should succeed");
room_service
.admin_delete_room(&room.id, &admin.id)
.await
.checked("test operation should succeed");
let result = room_service
.admin_delete_orphaned_room(&room.id, &admin.id)
.await;
assert!(result.is_err(), "Should reject double deletion");
let missing_room_id = RoomId::new();
let result = room_service
.admin_delete_orphaned_room(&missing_room_id, &admin.id)
.await;
assert!(
result.is_err(),
"Should reject deletion of nonexistent room"
);
}
async fn make_runtime_settings_store(pool: PgPool) -> Arc<RuntimeSettingsStore> {
let settings_repo = SettingsRepository::new(pool.clone());
let settings_service = Arc::new(SettingsService::new(settings_repo, pool.clone()));
let registry = Arc::new(RuntimeSettingsStore::new(settings_service));
// Seed the settings rows that tests may need
for (key, group, default_value) in [
(
synctv_core::service::RoomCreationPasswordPolicySetting::KEY,
"room_creation",
"optional",
),
(
synctv_core::service::RoomCreationEnabledSetting::KEY,
"room_creation",
"true",
),
(
synctv_core::service::MaxRoomsPerUserSetting::KEY,
"room_creation",
"10",
),
(
synctv_core::service::RoomCreationApprovalRequiredSetting::KEY,
"room_creation",
"false",
),
] {
sqlx::query!(
"INSERT INTO settings (key, group_name, value) VALUES ($1, $2, $3) ON CONFLICT (key) DO NOTHING",
key,
group,
default_value
)
.execute(&pool)
.await
.checked("test operation should succeed");
}
registry
}
async fn persist_test_runtime_settings(
registry: &RuntimeSettingsStore,
update: impl FnOnce(&mut synctv_core::service::RuntimeSettings),
) {
let mut settings = registry
.runtime_settings()
.checked("runtime settings should load");
update(&mut settings);
registry
.persist_runtime_settings(&settings)
.await
.checked("runtime settings should persist");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_create_room_enforces_password_policy_matrix() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry.clone());
let owner = user_repo
.create(&make_user("pwd_policy_matrix_owner"))
.await
.checked("test operation should succeed");
registry
.set_room_password_policy(RoomPasswordPolicy::Required)
.await
.checked("test operation should succeed");
let result = room_service
.create_room(
"Required Policy Open Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await;
assert!(matches!(
result,
Err(Error::InvalidInput(ref msg)) if msg.contains("password is required")
));
room_service
.create_room(
"Required Policy Password Room".to_string(),
String::new(),
owner.id,
Some("StrongPassword123".to_string()),
None,
)
.await
.checked("required policy should allow password-protected rooms");
registry
.set_room_password_policy(RoomPasswordPolicy::Forbidden)
.await
.checked("test operation should succeed");
let result = room_service
.create_room(
"Forbidden Policy Password Room".to_string(),
String::new(),
owner.id,
Some("UnwantedPassword123".to_string()),
None,
)
.await;
assert!(matches!(
result,
Err(Error::InvalidInput(ref msg)) if msg.contains("not allowed")
));
room_service
.create_room(
"Forbidden Policy Open Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("forbidden policy should allow open rooms");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_transfer_room_ownership_updates_room_and_member_roles() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let old_owner = user_repo
.create(&make_user("room_transfer_owner"))
.await
.checked("test operation should succeed");
let new_owner = user_repo
.create(&make_user("room_transfer_target"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Ownership Transfer Room".to_string(),
"transfer test".to_string(),
old_owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, new_owner.id, None)
.await
.checked("test operation should succeed");
let updated_room = room_service
.transfer_room_ownership(room.id, old_owner.id, new_owner.id)
.await
.checked("room ownership transfer should succeed");
assert_eq!(updated_room.created_by, new_owner.id);
let old_owner_member = member_repo
.get(&room.id, &old_owner.id)
.await
.checked("test operation should succeed")
.checked("old owner should remain a room member");
assert_eq!(old_owner_member.role, RoomRole::Admin);
let new_owner_member = member_repo
.get(&room.id, &new_owner.id)
.await
.checked("test operation should succeed")
.checked("new owner should remain a room member");
assert_eq!(new_owner_member.role, RoomRole::Creator);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_transfer_room_ownership_respects_max_rooms_per_user() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
persist_test_runtime_settings(&registry, |settings| {
settings.room_creation.max_rooms_per_user = 1;
})
.await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let old_owner = user_repo
.create(&make_user("room_transfer_limit_owner"))
.await
.checked("test operation should succeed");
let new_owner = user_repo
.create(&make_user("room_transfer_limit_target"))
.await
.checked("test operation should succeed");
let (room_to_transfer, _) = room_service
.create_room(
"Transfer Source Room".to_string(),
String::new(),
old_owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let (_existing_room, _) = room_service
.create_room(
"Already Owned Room".to_string(),
String::new(),
new_owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room_to_transfer.id, new_owner.id, None)
.await
.checked("test operation should succeed");
let err = room_service
.transfer_room_ownership(room_to_transfer.id, old_owner.id, new_owner.id)
.await
.failed("ownership transfer should fail when new owner reached room limit");
assert!(
matches!(err, Error::InvalidInput(ref msg) if msg.contains("maximum number of rooms")),
"error should explain room ownership limit, got: {err:?}"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_transfer_room_ownership_rejects_duplicate_name_for_new_owner() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let old_owner = user_repo
.create(&make_user("room_transfer_dup_owner"))
.await
.checked("test operation should succeed");
let new_owner = user_repo
.create(&make_user("room_transfer_dup_target"))
.await
.checked("test operation should succeed");
let (room_to_transfer, _) = room_service
.create_room(
"Shared Transfer Name".to_string(),
String::new(),
old_owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.create_room(
"Shared Transfer Name".to_string(),
String::new(),
new_owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room_to_transfer.id, new_owner.id, None)
.await
.checked("test operation should succeed");
let err = room_service
.transfer_room_ownership(room_to_transfer.id, old_owner.id, new_owner.id)
.await
.failed("ownership transfer should fail when new owner has same room name");
assert!(matches!(
err,
Error::AlreadyExists(ref msg) if msg == "You already have a room with this name"
));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_create_room_respects_max_rooms_per_user() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
persist_test_runtime_settings(&registry, |settings| {
settings.room_creation.max_rooms_per_user = 1;
})
.await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let owner = user_repo
.create(&make_user("room_create_limit_owner"))
.await
.checked("test operation should succeed");
room_service
.create_room(
"First Limited Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let err = room_service
.create_room(
"Second Limited Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.failed("second room should exceed max_rooms_per_user");
assert!(
matches!(err, Error::InvalidInput(ref msg) if msg.contains("maximum number of rooms")),
"error should explain room creation limit, got: {err:?}"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_concurrent_create_room_respects_max_rooms_per_user() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
persist_test_runtime_settings(&registry, |settings| {
settings.room_creation.max_rooms_per_user = 2;
})
.await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let owner = user_repo
.create(&make_user("room_create_concurrent_limit_owner"))
.await
.checked("test operation should succeed");
room_service
.create_room(
"Existing Limited Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("test operation should succeed");
let room_service = Arc::new(room_service);
let barrier = Arc::new(tokio::sync::Barrier::new(2));
let mut handles = Vec::new();
for name in ["Concurrent Limited Room A", "Concurrent Limited Room B"] {
let room_service = room_service.clone();
let barrier = barrier.clone();
let owner_id = owner.id;
let name = name.to_string();
handles.push(tokio::spawn(async move {
barrier.wait().await;
room_service
.create_room(name, String::new(), owner_id, None, None)
.await
}));
}
let results = futures::future::join_all(handles).await;
let mut success_count = 0;
let mut limit_error_count = 0;
for result in results {
match result.checked("concurrent create task should not panic") {
Ok(_) => success_count += 1,
Err(Error::InvalidInput(msg)) if msg.contains("maximum number of rooms") => {
limit_error_count += 1;
}
Err(err) => {
std::panic::panic_any(format!("unexpected concurrent create result: {err:?}"))
}
}
}
assert_eq!(
success_count, 1,
"only one concurrent room should fit the limit"
);
assert_eq!(
limit_error_count, 1,
"the losing concurrent create should be rejected by max_rooms_per_user"
);
let (_rooms, total) = room_service
.list_rooms_by_creator(&owner.id, PageParams::new(Some(1), Some(10)))
.await
.checked("test operation should succeed");
assert_eq!(
total, 2,
"final room count must not exceed max_rooms_per_user"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_create_room_rejects_banned_creator_in_service_layer() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let user_service = make_user_service(&pool);
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("room_create_banned_owner"))
.await
.checked("test operation should succeed");
user_service
.ban_user(&owner.id, None, Some("test ban".to_string()))
.await
.checked("test operation should succeed");
let err = room_service
.create_room(
"Banned Creator Room".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.failed("service layer must reject banned room creators");
assert!(matches!(
err,
Error::Authorization(ref msg) if msg.contains("cannot create rooms")
));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_pending_room_creation_rejects_duplicate_active_room_name() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
persist_test_runtime_settings(&registry, |settings| {
settings.room_creation.approval_required = true;
})
.await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let owner = user_repo
.create(&make_user("pending_room_dup_owner"))
.await
.checked("test operation should succeed");
room_service
.create_room(
"Pending Duplicate Name".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("first request should create a pending room");
sqlx::query!(
r#"
UPDATE room_creation_requests
SET status = $2, reviewed_at = CURRENT_TIMESTAMP
WHERE requested_by = $1
"#,
owner.id.as_i64(),
i16::from(synctv_core::models::ReviewStatus::Approved)
)
.execute(&pool)
.await
.checked("test operation should succeed");
let room_repo = RoomRepository::new(pool.clone());
let approved_room = synctv_core::models::Room::new_with_description(
"Pending Duplicate Name".to_string(),
String::new(),
owner.id,
);
room_repo
.create(&approved_room)
.await
.checked("test operation should succeed");
let err = room_service
.create_room(
"Pending Duplicate Name".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.failed("new pending request should fail when active room already has this name");
assert!(matches!(
err,
Error::AlreadyExists(ref msg) if msg == "You already have a room with this name"
));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_pending_room_creation_rejects_duplicate_pending_room_name() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
persist_test_runtime_settings(&registry, |settings| {
settings.room_creation.approval_required = true;
})
.await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let owner = user_repo
.create(&make_user("pending_room_same_name_owner"))
.await
.checked("test operation should succeed");
room_service
.create_room(
"Duplicate Pending Name".to_string(),
"first".to_string(),
owner.id,
None,
None,
)
.await
.checked("first request should create a pending room");
let err = room_service
.create_room(
"Duplicate Pending Name".to_string(),
"second".to_string(),
owner.id,
None,
None,
)
.await
.failed("second same-name pending request should be rejected");
assert!(matches!(
err,
Error::AlreadyExists(ref msg) if msg == "You already have a room with this name"
));
let pending_count: i64 = sqlx::query_scalar!(
r#"
SELECT COUNT(*) AS "count!"
FROM room_creation_requests
WHERE requested_by = $1
AND name = $2
AND reviewed_at IS NULL
"#,
owner.id.as_i64(),
"Duplicate Pending Name"
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(
pending_count, 1,
"service policy should keep only one pending request for this creator/name"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_approve_pending_room_allows_the_request_itself_while_checking_name_policy() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
persist_test_runtime_settings(&registry, |settings| {
settings.room_creation.approval_required = true;
})
.await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let owner = user_repo
.create(&make_user("pending_room_self_exclusion_owner"))
.await
.checked("test operation should succeed");
let (pending_room, _) = room_service
.create_room(
"Self Exclusion Pending Name".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("pending room request should be created");
let approved = room_service
.approve_pending_room(pending_room.id, None)
.await
.checked("approval should not treat the current pending row as a duplicate");
assert_eq!(approved.name, "Self Exclusion Pending Name");
assert_eq!(approved.created_by, owner.id);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_approve_pending_room_preserves_password_when_policy_required() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let registry = make_runtime_settings_store(pool.clone()).await;
persist_test_runtime_settings(&registry, |settings| {
settings.room_creation.approval_required = true;
})
.await;
registry
.set_room_password_policy(RoomPasswordPolicy::Required)
.await
.checked("test operation should succeed");
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let owner = user_repo
.create(&make_user("pending_room_password_required_owner"))
.await
.checked("test operation should succeed");
let visitor = user_repo
.create(&make_user("pending_room_password_required_visitor"))
.await
.checked("test operation should succeed");
let (pending_room, _) = room_service
.create_room(
"Pending Password Required Room".to_string(),
String::new(),
owner.id,
Some("StrongPassword123".to_string()),
None,
)
.await
.checked("password should satisfy required policy while room awaits review");
let approved = room_service
.approve_pending_room(pending_room.id, None)
.await
.checked("approval should create the password-protected room");
assert_eq!(approved.name, "Pending Password Required Room");
assert!(
room_service
.is_room_password_enabled(&approved.id)
.await
.checked("test operation should succeed"),
"approved room should keep the pending password enabled"
);
let wrong_password = room_service
.join_room(
approved.id,
visitor.id,
Some("WrongPassword123".to_string()),
)
.await
.failed("wrong password should not join the approved room");
assert!(matches!(
wrong_password,
Error::Authorization(ref message) if message.contains("Invalid password")
));
room_service
.join_room(
approved.id,
visitor.id,
Some("StrongPassword123".to_string()),
)
.await
.checked("correct password should join the approved room");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_approve_pending_room_rejects_creator_banned_after_request() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let user_service = make_user_service(&pool);
let registry = make_runtime_settings_store(pool.clone()).await;
persist_test_runtime_settings(&registry, |settings| {
settings.room_creation.approval_required = true;
})
.await;
let room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let owner = user_repo
.create(&make_user("pending_room_banned_after_request_owner"))
.await
.checked("test operation should succeed");
let (pending_room, _) = room_service
.create_room(
"Pending Room Banned Later".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.checked("request should be accepted while creator is active");
user_service
.ban_user(&owner.id, None, Some("test ban".to_string()))
.await
.checked("test operation should succeed");
let err = room_service
.approve_pending_room(pending_room.id, None)
.await
.failed("approval must re-check creator current status");
assert!(matches!(
err,
Error::Authorization(ref msg) if msg.contains("cannot create rooms")
));
let room_exists = RoomRepository::new(pool.clone())
.get_by_id(&pending_room.id)
.await
.checked("test operation should succeed")
.is_some();
assert!(
!room_exists,
"failed approval must not create an active room row"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_set_member_role_only_creator_can_change_roles() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let creator = user_repo
.create(&make_user("role_creator"))
.await
.checked("test operation should succeed");
let admin_user = user_repo
.create(&make_user("role_admin"))
.await
.checked("test operation should succeed");
let member_user = user_repo
.create(&make_user("role_member"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Role Test Room".to_string(),
"Testing roles".to_string(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
// Join admin and member
room_service
.join_room(room.id, admin_user.id, None)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, member_user.id, None)
.await
.checked("test operation should succeed");
// Creator promotes admin_user to Admin
room_service
.member_service()
.set_member_role(room.id, creator.id, admin_user.id, RoomRole::Admin)
.await
.checked("test operation should succeed");
// Admin tries to change member_user's role -- should fail (creator-only)
let result = room_service
.member_service()
.set_member_role(room.id, admin_user.id, member_user.id, RoomRole::Admin)
.await;
assert!(
result.is_err(),
"Admin should not be able to change roles (creator-only)"
);
let err = result.failed("operation should fail");
assert!(
matches!(err, Error::Authorization(ref msg) if msg.contains("creator")),
"Error should mention creator-only restriction, got: {err:?}"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_taxonomy_is_admin_curated_and_filterable() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let owner = user_repo
.create(&make_user("taxonomy_owner"))
.await
.checked("test operation should succeed");
let movie = room_service
.upsert_room_category(UpsertRoomCategory {
key: "movie".to_string(),
name: "Movie".to_string(),
description: String::new(),
sort_order: 10,
is_enabled: true,
})
.await
.checked("test operation should succeed");
let game = room_service
.upsert_room_category(UpsertRoomCategory {
key: "game".to_string(),
name: "Game".to_string(),
description: String::new(),
sort_order: 20,
is_enabled: true,
})
.await
.checked("test operation should succeed");
let anime = room_service
.upsert_room_label(UpsertRoomLabel {
key: "anime".to_string(),
name: "Anime".to_string(),
description: String::new(),
color: "#FFAA00".to_string(),
category_id: Some(movie.id),
sort_order: 1,
is_enabled: true,
})
.await
.checked("test operation should succeed");
let coop = room_service
.upsert_room_label(UpsertRoomLabel {
key: "coop".to_string(),
name: "Co-op".to_string(),
description: String::new(),
color: String::new(),
category_id: Some(game.id),
sort_order: 2,
is_enabled: true,
})
.await
.checked("test operation should succeed");
let disabled = room_service
.upsert_room_label(UpsertRoomLabel {
key: "disabled_tag".to_string(),
name: "Disabled".to_string(),
description: String::new(),
color: String::new(),
category_id: Some(movie.id),
sort_order: 3,
is_enabled: false,
})
.await
.checked("test operation should succeed");
let (movie_room, _) = room_service
.create_room_with_taxonomy_outbox(
CreateRoomWithTaxonomyRequest {
name: "Taxonomy Movie".to_string(),
description: String::new(),
created_by: owner.id,
password: None,
settings: None,
category_id: Some(movie.id),
label_ids: vec![anime.id],
is_public: true,
},
None,
)
.await
.checked("test operation should succeed");
let (game_room, _) = room_service
.create_room_with_taxonomy_outbox(
CreateRoomWithTaxonomyRequest {
name: "Taxonomy Game".to_string(),
description: String::new(),
created_by: owner.id,
password: None,
settings: None,
category_id: Some(game.id),
label_ids: Vec::new(),
is_public: true,
},
None,
)
.await
.checked("test operation should succeed");
assert_eq!(
movie_room.category.as_ref().map(|category| category.id),
Some(movie.id)
);
assert_eq!(
movie_room
.labels
.iter()
.map(|label| label.id)
.collect::<Vec<_>>(),
vec![anime.id]
);
let mut renamed_movie_room = movie_room.clone();
renamed_movie_room.name = "Taxonomy Movie Renamed".to_string();
let updated_movie_room = RoomRepository::new(pool.clone())
.update(&renamed_movie_room, movie_room.version)
.await
.checked("repository update should preserve category");
assert_eq!(
updated_movie_room
.category
.as_ref()
.map(|category| category.id),
Some(movie.id)
);
let (movie_rooms, total) = room_service
.list_accessible_rooms(&RoomListQuery {
category_id: Some(movie.id),
..Default::default()
})
.await
.checked("test operation should succeed");
assert_eq!(total, 1);
assert_eq!(movie_rooms[0].id, movie_room.id);
assert_eq!(
movie_rooms[0].category.as_ref().map(|category| category.id),
Some(movie.id)
);
assert_eq!(
movie_rooms[0]
.labels
.iter()
.map(|label| label.id)
.collect::<Vec<_>>(),
vec![anime.id]
);
let (anime_rooms, total) = room_service
.list_accessible_rooms(&RoomListQuery {
label_ids: vec![anime.id],
..Default::default()
})
.await
.checked("test operation should succeed");
assert_eq!(total, 1);
assert_eq!(anime_rooms[0].id, movie_room.id);
let (duplicate_anime_rooms, total) = room_service
.list_accessible_rooms(&RoomListQuery {
label_ids: vec![anime.id, anime.id],
..Default::default()
})
.await
.checked("duplicate label filters should be deduped");
assert_eq!(total, 1);
assert_eq!(duplicate_anime_rooms[0].id, movie_room.id);
let (creator_rooms, _) = room_service
.list_rooms_by_creator(&owner.id, PageParams::new(Some(1), Some(10)))
.await
.checked("creator room list should load taxonomy");
let creator_movie_room = creator_rooms
.iter()
.find(|room| room.id == movie_room.id)
.expect("creator room list should contain movie room");
assert_eq!(
creator_movie_room
.category
.as_ref()
.map(|category| category.id),
Some(movie.id)
);
assert_eq!(
creator_movie_room
.labels
.iter()
.map(|label| label.id)
.collect::<Vec<_>>(),
vec![anime.id]
);
let (joined_rooms, _) = room_service
.list_joined_rooms_with_details(&owner.id, PageParams::new(Some(1), Some(10)))
.await
.checked("joined room list should load taxonomy");
let joined_movie_room = joined_rooms
.iter()
.map(|(room, _, _, _)| room)
.find(|room| room.id == movie_room.id)
.expect("joined room list should contain movie room");
assert_eq!(
joined_movie_room
.category
.as_ref()
.map(|category| category.id),
Some(movie.id)
);
assert_eq!(
joined_movie_room
.labels
.iter()
.map(|label| label.id)
.collect::<Vec<_>>(),
vec![anime.id]
);
let mismatch = room_service
.create_room_with_taxonomy_outbox(
CreateRoomWithTaxonomyRequest {
name: "Taxonomy Mismatch".to_string(),
description: String::new(),
created_by: owner.id,
password: None,
settings: None,
category_id: Some(movie.id),
label_ids: vec![coop.id],
is_public: true,
},
None,
)
.await;
assert!(
matches!(mismatch, Err(Error::InvalidInput(ref msg)) if msg.contains("selected category")),
"category-scoped labels should be rejected for other categories"
);
let disabled_label = room_service
.create_room_with_taxonomy_outbox(
CreateRoomWithTaxonomyRequest {
name: "Taxonomy Disabled Label".to_string(),
description: String::new(),
created_by: owner.id,
password: None,
settings: None,
category_id: Some(movie.id),
label_ids: vec![disabled.id],
is_public: true,
},
None,
)
.await;
assert!(
matches!(disabled_label, Err(Error::InvalidInput(ref msg)) if msg.contains("disabled")),
"disabled labels should be rejected"
);
let inactive = room_service
.upsert_room_category(UpsertRoomCategory {
key: "inactive_room_category".to_string(),
name: "Inactive".to_string(),
description: String::new(),
sort_order: 30,
is_enabled: false,
})
.await
.checked("test operation should succeed");
let disabled_category = room_service
.create_room_with_taxonomy_outbox(
CreateRoomWithTaxonomyRequest {
name: "Taxonomy Disabled Category".to_string(),
description: String::new(),
created_by: owner.id,
password: None,
settings: None,
category_id: Some(inactive.id),
label_ids: Vec::new(),
is_public: true,
},
None,
)
.await;
assert!(
matches!(disabled_category, Err(Error::InvalidInput(ref msg)) if msg.contains("disabled")),
"disabled categories should be rejected"
);
room_service
.update_room_taxonomy(
movie_room.id,
RoomCategoryUpdate::Preserve,
&[anime.id],
Some(owner.id),
)
.await
.checked("preserve category update should succeed");
let preserved_room = RoomRepository::new(pool.clone())
.get_by_id(&movie_room.id)
.await
.checked("updated room should load")
.checked("updated room should exist");
assert_eq!(
preserved_room.category.as_ref().map(|category| category.id),
Some(movie.id),
"taxonomy update without category should keep the current category"
);
let invalid_update = room_service
.update_room_taxonomy(
movie_room.id,
RoomCategoryUpdate::Set(Some(movie.id)),
&[coop.id],
Some(owner.id),
)
.await;
assert!(
matches!(invalid_update, Err(Error::InvalidInput(ref msg)) if msg.contains("selected category")),
"taxonomy updates should validate labels in the service layer"
);
let labels_for_movie = room_service
.list_room_labels(true, Some(movie.id))
.await
.checked("test operation should succeed");
assert_eq!(
labels_for_movie
.iter()
.map(|label| label.id)
.collect::<Vec<_>>(),
vec![anime.id]
);
let labels_with_disabled = room_service
.list_room_labels(false, Some(movie.id))
.await
.checked("test operation should succeed");
assert!(labels_with_disabled
.iter()
.any(|label| label.id == disabled.id));
let (all_rooms, total) = room_service
.list_accessible_rooms(&RoomListQuery::default())
.await
.checked("test operation should succeed");
assert_eq!(total, 2);
assert_eq!(
all_rooms
.iter()
.map(|room| room.id)
.collect::<std::collections::HashSet<_>>(),
[movie_room.id, game_room.id].into_iter().collect()
);
let registry = make_runtime_settings_store(pool.clone()).await;
persist_test_runtime_settings(&registry, |settings| {
settings.room_creation.approval_required = true;
})
.await;
let review_room_service = make_room_service_with_runtime_settings_store(&pool, registry);
let review_owner = user_repo
.create(&make_user("taxonomy_review_owner"))
.await
.checked("test operation should succeed");
let (pending_room, _) = review_room_service
.create_room_with_taxonomy_outbox(
CreateRoomWithTaxonomyRequest {
name: "Taxonomy Pending Review".to_string(),
description: String::new(),
created_by: review_owner.id,
password: None,
settings: None,
category_id: Some(movie.id),
label_ids: vec![anime.id],
is_public: false,
},
None,
)
.await
.checked("pending room request should keep taxonomy");
assert_eq!(
pending_room.category.as_ref().map(|category| category.id),
Some(movie.id)
);
assert_eq!(
pending_room
.labels
.iter()
.map(|label| label.id)
.collect::<Vec<_>>(),
vec![anime.id]
);
assert!(!pending_room.is_public);
let approved_room = review_room_service
.approve_pending_room(pending_room.id, None)
.await
.checked("approved room should return taxonomy");
assert_eq!(
approved_room.category.as_ref().map(|category| category.id),
Some(movie.id)
);
assert_eq!(
approved_room
.labels
.iter()
.map(|label| label.id)
.collect::<Vec<_>>(),
vec![anime.id]
);
assert!(!approved_room.is_public);
let (stale_pending_room, _) = review_room_service
.create_room_with_taxonomy_outbox(
CreateRoomWithTaxonomyRequest {
name: "Taxonomy Stale Pending Review".to_string(),
description: String::new(),
created_by: review_owner.id,
password: None,
settings: None,
category_id: Some(movie.id),
label_ids: vec![anime.id],
is_public: true,
},
None,
)
.await
.checked("pending room request should be created before taxonomy changes");
room_service
.upsert_room_label(UpsertRoomLabel {
key: "anime".to_string(),
name: "Anime".to_string(),
description: String::new(),
color: "#FFAA00".to_string(),
category_id: Some(movie.id),
sort_order: 1,
is_enabled: false,
})
.await
.checked("test operation should succeed");
let stale_approval = review_room_service
.approve_pending_room(stale_pending_room.id, None)
.await;
assert!(
matches!(stale_approval, Err(Error::InvalidInput(ref msg)) if msg.contains("disabled")),
"approval should re-check current taxonomy state"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_kick_member_completes_quickly() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let creator = user_repo
.create(&make_user("kick_creator"))
.await
.checked("test operation should succeed");
let target = user_repo
.create(&make_user("kick_target"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Kick Test Room".to_string(),
"Testing kick".to_string(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, target.id, None)
.await
.checked("test operation should succeed");
// Kick should complete without fixed sleep overhead
let start = std::time::Instant::now();
room_service
.kick_member(room.id, creator.id, target.id, 60)
.await
.checked("test operation should succeed");
let elapsed = start.elapsed();
// Without the sleep, this should complete well under 100ms
// (allowing generous margin for DB operations)
// The main point is it shouldn't have the hardcoded 100ms sleep
assert!(
elapsed < std::time::Duration::from_secs(5),
"Kick operation took too long: {elapsed:?}"
);
assert!(
member_repo
.get_any(&room.id, &target.id)
.await
.checked("test operation should succeed")
.is_none(),
"kick should delete the member row"
);
assert!(
member_repo
.is_in_kick_cooldown(&room.id, &target.id)
.await
.checked("test operation should succeed"),
"kick should create cooldown"
);
}