mirror of https://github.com/synctv-org/synctv
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.
7433 lines
228 KiB
Rust
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, ®ular_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(®istry, |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, ®ular_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(®istry, |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(®istry, |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(®istry, |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(®istry, |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(®istry, |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(®istry, |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(®istry, |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(®istry, |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(®istry, |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"
|
|
);
|
|
}
|