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

583 lines
18 KiB
Rust

//! Room CRUD integration tests
//!
//! Tests the complete room lifecycle: create, read, update, soft delete, CASCADE behavior.
//!
use chrono::Utc;
use synctv_core::{
models::{
Playlist, PlaylistId, Room, RoomId, RoomMember, RoomStatus, User, UserId, UserRole,
UserStatus,
},
repository::{PlaylistRepository, RoomMemberRepository, RoomRepository, UserRepository},
};
use synctv_core_testing::create_test_pool;
use synctv_core_testing::TestResultExt;
/// Default `PostgreSQL` version for test containers
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_room(name: &str, description: &str, owner: &UserId) -> Room {
let now = Utc::now();
Room {
id: RoomId::new(),
name: name.to_string(),
description: description.to_string(),
cover_file_reference_id: None,
category: None,
labels: Vec::new(),
created_by: *owner,
status: RoomStatus::Active,
is_banned: false,
is_public: true,
closed_at: None,
created_at: now,
updated_at: now,
deleted_at: None,
version: 0,
last_activity_at: now,
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_create_room_basic() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("room_owner_1"))
.await
.checked("test operation should succeed");
let room = make_room("Test Room", "A test room", &owner.id);
let created = room_repo
.create(&room)
.await
.checked("test operation should succeed");
assert_eq!(created.name, "Test Room");
assert_eq!(created.description, "A test room");
assert_eq!(created.created_by, owner.id);
assert_eq!(created.status, RoomStatus::Active);
assert!(!created.is_banned);
assert!(created.deleted_at.is_none());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_create_room_description_length() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("room_owner_2"))
.await
.checked("test operation should succeed");
// 500 characters should be fine (description is TEXT, no DB-level length constraint,
// but application layer should enforce <= 500)
let long_desc = "a".repeat(500);
let room = make_room("Long Desc Room", &long_desc, &owner.id);
let created = room_repo
.create(&room)
.await
.checked("test operation should succeed");
assert_eq!(created.description.len(), 500);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_room_settings() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("room_owner_3"))
.await
.checked("test operation should succeed");
let room = make_room("Original Name", "original desc", &owner.id);
let created = room_repo
.create(&room)
.await
.checked("test operation should succeed");
// Update name and description
let mut updated_room = created.clone();
updated_room.name = "Updated Name".to_string();
updated_room.description = "updated desc".to_string();
let updated = room_repo
.update(&updated_room, created.version)
.await
.checked("test operation should succeed");
assert_eq!(updated.name, "Updated Name");
assert_eq!(updated.description, "updated desc");
assert!(updated.updated_at >= created.updated_at);
assert_eq!(updated.version, created.version + 1);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_room_status() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("room_owner_4"))
.await
.checked("test operation should succeed");
let room = make_room("Status Room", "", &owner.id);
let created = room_repo
.create(&room)
.await
.checked("test operation should succeed");
assert_eq!(created.status, RoomStatus::Active);
let updated = room_repo
.update_status(&created.id, RoomStatus::Closed)
.await
.checked("test operation should succeed");
assert_eq!(updated.status, RoomStatus::Closed);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_soft_delete_room() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("room_owner_5"))
.await
.checked("test operation should succeed");
let room = make_room("Delete Me", "", &owner.id);
let created = room_repo
.create(&room)
.await
.checked("test operation should succeed");
// Room should exist before delete
assert!(room_repo
.exists(&created.id)
.await
.checked("test operation should succeed"));
// Soft delete
let deleted = room_repo
.delete(&created.id)
.await
.checked("test operation should succeed");
assert!(deleted);
// Room should not be found by get_by_id (which filters deleted_at IS NULL)
let fetched = room_repo
.get_by_id(&created.id)
.await
.checked("test operation should succeed");
assert!(fetched.is_none());
// exists should return false
assert!(!room_repo
.exists(&created.id)
.await
.checked("test operation should succeed"));
// Double delete should return false (already deleted)
let deleted_again = room_repo
.delete(&created.id)
.await
.checked("test operation should succeed");
assert!(!deleted_again);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_cascade_delete_user_deletes_rooms() {
// The `rooms.created_by` FK uses ON DELETE RESTRICT (not CASCADE), so
// deleting a user who still owns rooms must fail. The correct application
// flow is: delete/transfer rooms first, then delete the user.
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("cascade_owner"))
.await
.checked("test operation should succeed");
let room1 = room_repo
.create(&make_room("Room 1", "", &owner.id))
.await
.checked("test operation should succeed");
let room2 = room_repo
.create(&make_room("Room 2", "", &owner.id))
.await
.checked("test operation should succeed");
assert!(room_repo
.exists(&room1.id)
.await
.checked("test operation should succeed"));
assert!(room_repo
.exists(&room2.id)
.await
.checked("test operation should succeed"));
// Attempting to delete the user while rooms still exist should fail
// because of ON DELETE RESTRICT on rooms.created_by.
let delete_result = sqlx::query!("DELETE FROM users WHERE id = $1", owner.id.as_i64())
.execute(&pool)
.await;
assert!(
delete_result.is_err(),
"Deleting a user with owned rooms should fail due to FK RESTRICT"
);
// Delete rooms first, then the user should succeed.
room_repo
.delete(&room1.id)
.await
.checked("test operation should succeed");
room_repo
.delete(&room2.id)
.await
.checked("test operation should succeed");
// Now that rooms are soft-deleted, hard-delete the rows so the FK is clear.
sqlx::query!(
"DELETE FROM rooms WHERE id = $1 OR id = $2",
room1.id.as_i64(),
room2.id.as_i64()
)
.execute(&pool)
.await
.checked("test operation should succeed");
sqlx::query!("DELETE FROM users WHERE id = $1", owner.id.as_i64())
.execute(&pool)
.await
.checked("test operation should succeed");
// User should be gone
let user_count: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) AS "count!" FROM users WHERE id = $1"#,
owner.id.as_i64()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(
user_count, 0,
"User should be deleted after rooms are removed"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_cascade_delete_room_deletes_members_and_playlists() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let playlist_repo = PlaylistRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("cascade_room_owner"))
.await
.checked("test operation should succeed");
let member_user = user_repo
.create(&make_user("cascade_member"))
.await
.checked("test operation should succeed");
let room = room_repo
.create(&make_room("Cascade Room", "", &owner.id))
.await
.checked("test operation should succeed");
// Add a member
let rm = RoomMember {
room_id: room.id,
user_id: member_user.id,
role: synctv_core::models::RoomRole::Member,
added_permissions: 0,
removed_permissions: 0,
admin_added_permissions: 0,
admin_removed_permissions: 0,
remark_name: String::new(),
display_tag: String::new(),
joined_at: Utc::now(),
version: 0,
};
member_repo
.add(&rm)
.await
.checked("test operation should succeed");
let root_playlist = Playlist {
id: PlaylistId::new(),
room_id: room.id,
creator_id: Some(owner.id),
browse_access_mode: synctv_core::models::PlaylistBrowseAccessMode::Default,
name: String::new(),
description: String::new(),
cover_file_reference_id: None,
parent_id: None,
position: 0.0,
source_provider: None,
source_config: None,
provider_instance_name: None,
created_at: Utc::now(),
updated_at: Utc::now(),
version: 0,
};
playlist_repo
.create(&root_playlist)
.await
.checked("test operation should succeed");
// Hard delete the room through the explicit cleanup path.
let deleted = room_repo
.hard_delete(&room.id)
.await
.checked("test operation should succeed");
assert!(deleted);
// Check members are gone
let member_count: 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, 0, "Room members should be explicitly deleted");
// Check playlists are gone
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, "Playlists should be explicitly deleted");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_concurrent_room_creation_unique_ids() {
use std::sync::Arc;
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = Arc::new(RoomRepository::new(pool.clone()));
let owner = user_repo
.create(&make_user("concurrent_owner"))
.await
.checked("test operation should succeed");
let owner_id = owner.id;
// Spawn 10 concurrent room creations
let mut handles = vec![];
for i in 0..10 {
let repo = room_repo.clone();
let oid = owner_id;
let handle = tokio::spawn(async move {
let room = make_room(&format!("Concurrent Room {i}"), "", &oid);
repo.create(&room).await
});
handles.push(handle);
}
let results: Vec<_> = futures::future::join_all(handles).await;
// All should succeed since each room gets a unique base62 ID
let mut created_ids = std::collections::HashSet::new();
for result in results {
let room = result
.checked("test operation should succeed")
.checked("test operation should succeed");
assert!(
created_ids.insert(room.id.to_string()),
"Room IDs should be unique"
);
}
assert_eq!(created_ids.len(), 10);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_room_ban_status() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("ban_owner"))
.await
.checked("test operation should succeed");
let room = room_repo
.create(&make_room("Ban Room", "", &owner.id))
.await
.checked("test operation should succeed");
assert!(!room.is_banned);
assert!(room_repo
.is_accessible(&room.id)
.await
.checked("test operation should succeed"));
// Ban the room
let banned = room_repo
.update_ban_status(&room.id, true)
.await
.checked("test operation should succeed");
assert!(banned.is_banned);
// Banned room should not be accessible
assert!(!room_repo
.is_accessible(&room.id)
.await
.checked("test operation should succeed"));
// Unban the room
let unbanned = room_repo
.update_ban_status(&room.id, false)
.await
.checked("test operation should succeed");
assert!(!unbanned.is_banned);
assert!(room_repo
.is_accessible(&room.id)
.await
.checked("test operation should succeed"));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_stale_version_returns_optimistic_lock_conflict() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("optimistic_owner"))
.await
.checked("test operation should succeed");
let room = make_room("Optimistic Room", "original", &owner.id);
let created = room_repo
.create(&room)
.await
.checked("test operation should succeed");
let original_version = created.version;
// First update succeeds
let mut updated_room = created.clone();
updated_room.name = "Updated Name V1".to_string();
updated_room.description = "updated v1".to_string();
let v1 = room_repo
.update(&updated_room, original_version)
.await
.checked("test operation should succeed");
assert_eq!(v1.version, original_version + 1);
assert_eq!(v1.name, "Updated Name V1");
// Second update with stale version (original_version) -> should get OptimisticLockConflict
let mut stale_room = created.clone();
stale_room.name = "Updated Name V2".to_string();
stale_room.description = "updated v2".to_string();
let err = room_repo
.update(&stale_room, original_version)
.await
.failed("operation should fail");
assert!(
matches!(err, synctv_core::Error::OptimisticLockConflict),
"Expected OptimisticLockConflict, got: {err:?}"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_soft_deleted_room_returns_not_found() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("softdel_owner"))
.await
.checked("test operation should succeed");
let room = make_room("Soft Delete Room", "", &owner.id);
let created = room_repo
.create(&room)
.await
.checked("test operation should succeed");
let version = created.version;
// Soft delete the room
let deleted = room_repo
.delete(&created.id)
.await
.checked("test operation should succeed");
assert!(deleted);
// Trying to update the deleted room should return NotFound (not OptimisticLockConflict)
let mut updated = created.clone();
updated.name = "Updated Soft Deleted".to_string();
let err = room_repo
.update(&updated, version)
.await
.failed("operation should fail");
assert!(
matches!(err, synctv_core::Error::NotFound(_)),
"Expected NotFound for soft-deleted room, got: {err:?}"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_nonexistent_room_returns_not_found() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("nonexistent_owner"))
.await
.checked("test operation should succeed");
let room = make_room("Nonexistent Room", "", &owner.id);
// Trying to update should return NotFound
let err = room_repo
.update(&room, 0)
.await
.failed("operation should fail");
assert!(
matches!(err, synctv_core::Error::NotFound(_)),
"Expected NotFound for nonexistent room, got: {err:?}"
);
}