|
|
|
|
//! Media CRUD integration tests
|
|
|
|
|
//! Tests media item creation, unique constraints, deletion, and playlist association.
|
|
|
|
|
//!
|
|
|
|
|
use chrono::Utc;
|
|
|
|
|
use sqlx::PgPool;
|
|
|
|
|
use synctv_core::{
|
|
|
|
|
models::{
|
feat: add recoverable resource deletion lifecycle (#394)
## Summary
- Add recoverable soft deletion for users, rooms, playlists, media, and
chat messages, with retention-based physical cleanup.
- Hide deleted users and owned resources from active API, room,
playback, messaging, and realtime paths while preserving restoration
metadata.
- Add user lifecycle administration across HTTP, gRPC, management
runtime, and SyncTV CLI, including deleted-user listing and restore
operations.
- Add lifecycle migration, cleanup orchestration, audit fields,
resource-change events, and refreshed SQLx offline metadata.
## Design
- Model account, administrator, system, room, and user deletion sources
explicitly so cascade ownership and audit history remain deterministic.
- Release email and OAuth identity occupancy during deletion;
restoration reclaims available identities and reports conflicts
according to restore options.
- Preserve recoverable aggregate rows during the retention window,
remove ephemeral membership and playback state immediately, and purge
expired data in dependency order.
- Wait for realtime observation acknowledgements before publishing
lifecycle changes to avoid subscription registration races.
## Verification
- `make nextest`: 6707 passed, 0 skipped
- `make clippy`: passed for the workspace and all targets
- `make clippy-check`: passed
- `cargo fmt --all -- --check`: passed
- `git diff --check`: passed
2 months ago
|
|
|
DeletionSource, Media, MediaId, Playlist, PlaylistId, ProviderType, Room, RoomId,
|
|
|
|
|
RoomMember, RoomRole, RoomStatus, SourceProvider, User, UserId, UserRole, UserStatus,
|
|
|
|
|
},
|
|
|
|
|
repository::{
|
|
|
|
|
MediaRepository, PlaylistRepository, RoomMemberRepository, RoomRepository, UserRepository,
|
|
|
|
|
},
|
|
|
|
|
service::DeleteEntriesRequest,
|
|
|
|
|
};
|
|
|
|
|
use synctv_core_testing::{
|
|
|
|
|
create_test_pool, create_test_room_service, TestContainer, TestOptionExt, TestResultExt,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fn assert_f64_eq(actual: f64, expected: f64) {
|
|
|
|
|
assert!(
|
|
|
|
|
(actual - expected).abs() < f64::EPSILON,
|
|
|
|
|
"expected {expected}, got {actual}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn usize_to_f64(value: usize) -> f64 {
|
|
|
|
|
f64::from(u32::try_from(value).unwrap_or(u32::MAX))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct TestContext {
|
|
|
|
|
_container: TestContainer,
|
|
|
|
|
pool: PgPool,
|
|
|
|
|
owner: User,
|
|
|
|
|
room: Room,
|
|
|
|
|
root_playlist: Playlist,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn setup_test_context(suffix: &str) -> TestContext {
|
|
|
|
|
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(&format!("media_owner_{suffix}")))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let room = room_repo
|
|
|
|
|
.create(&{
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
Room {
|
|
|
|
|
id: RoomId::new(),
|
|
|
|
|
name: format!("Media Room {suffix}"),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
category: None,
|
|
|
|
|
labels: Vec::new(),
|
|
|
|
|
created_by: owner.id,
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
member_repo
|
|
|
|
|
.add(&RoomMember::new(room.id, owner.id, RoomRole::Creator))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let root_playlist = playlist_repo
|
|
|
|
|
.create(&Playlist {
|
|
|
|
|
id: PlaylistId::new(),
|
|
|
|
|
room_id: room.id,
|
|
|
|
|
creator_id: Some(owner.id),
|
|
|
|
|
browse_access_mode: synctv_core::models::PlaylistBrowseAccessMode::Default,
|
|
|
|
|
name: "Top Level".to_string(),
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
TestContext {
|
|
|
|
|
_container: container,
|
|
|
|
|
pool,
|
|
|
|
|
owner,
|
|
|
|
|
room,
|
|
|
|
|
root_playlist,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn make_media(playlist_id: &PlaylistId, room_id: &RoomId, name: &str, position: i32) -> Media {
|
|
|
|
|
Media {
|
|
|
|
|
id: MediaId::new(),
|
|
|
|
|
playlist_id: Some(*playlist_id),
|
|
|
|
|
room_id: *room_id,
|
|
|
|
|
creator_id: None,
|
|
|
|
|
name: name.to_string(),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
position: f64::from(position),
|
|
|
|
|
source_provider: SourceProvider::DirectUrl,
|
|
|
|
|
source_config: synctv_core_testing::direct_url_media_source_config(
|
|
|
|
|
"https://example.com/video.mp4",
|
|
|
|
|
),
|
|
|
|
|
provider_instance_name: None,
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
thumbnail_file_reference_id: None,
|
|
|
|
|
added_at: Utc::now(),
|
|
|
|
|
updated_at: Utc::now(),
|
|
|
|
|
version: 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn make_room_root_media(room_id: &RoomId, name: &str, position: i32) -> Media {
|
|
|
|
|
Media {
|
|
|
|
|
id: MediaId::new(),
|
|
|
|
|
playlist_id: None,
|
|
|
|
|
room_id: *room_id,
|
|
|
|
|
creator_id: None,
|
|
|
|
|
name: name.to_string(),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
position: f64::from(position),
|
|
|
|
|
source_provider: SourceProvider::DirectUrl,
|
|
|
|
|
source_config: synctv_core_testing::direct_url_media_source_config(
|
|
|
|
|
"https://example.com/video.mp4",
|
|
|
|
|
),
|
|
|
|
|
provider_instance_name: None,
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
thumbnail_file_reference_id: None,
|
|
|
|
|
added_at: Utc::now(),
|
|
|
|
|
updated_at: Utc::now(),
|
|
|
|
|
version: 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_create_media_basic() {
|
|
|
|
|
let ctx = setup_test_context("1").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let media = make_media(&ctx.root_playlist.id, &ctx.room.id, "test_video.mp4", 0);
|
|
|
|
|
let created = media_repo
|
|
|
|
|
.create(&media)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
assert_eq!(created.name, "test_video.mp4");
|
|
|
|
|
assert_f64_eq(created.position, 0.0);
|
|
|
|
|
assert_eq!(created.source_provider, SourceProvider::DirectUrl);
|
|
|
|
|
assert_eq!(created.playlist_id, Some(ctx.root_playlist.id));
|
|
|
|
|
assert_eq!(created.room_id, ctx.room.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_media_get_by_id() {
|
|
|
|
|
let ctx = setup_test_context("2").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let media = make_media(&ctx.root_playlist.id, &ctx.room.id, "get_me.mp4", 0);
|
|
|
|
|
let created = media_repo
|
|
|
|
|
.create(&media)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let fetched = media_repo
|
|
|
|
|
.get_by_id(&created.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert!(fetched.is_some());
|
|
|
|
|
let fetched = fetched.checked("test operation should succeed");
|
|
|
|
|
assert_eq!(fetched.id, created.id);
|
|
|
|
|
assert_eq!(fetched.name, "get_me.mp4");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_media_delete() {
|
|
|
|
|
let ctx = setup_test_context("4").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let media = make_media(&ctx.root_playlist.id, &ctx.room.id, "delete_me.mp4", 0);
|
|
|
|
|
let created = media_repo
|
|
|
|
|
.create(&media)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let deleted = media_repo
|
|
|
|
|
.delete(&created.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert!(deleted);
|
|
|
|
|
|
|
|
|
|
let fetched = media_repo
|
|
|
|
|
.get_by_id(&created.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert!(fetched.is_none());
|
|
|
|
|
|
feat: add recoverable resource deletion lifecycle (#394)
## Summary
- Add recoverable soft deletion for users, rooms, playlists, media, and
chat messages, with retention-based physical cleanup.
- Hide deleted users and owned resources from active API, room,
playback, messaging, and realtime paths while preserving restoration
metadata.
- Add user lifecycle administration across HTTP, gRPC, management
runtime, and SyncTV CLI, including deleted-user listing and restore
operations.
- Add lifecycle migration, cleanup orchestration, audit fields,
resource-change events, and refreshed SQLx offline metadata.
## Design
- Model account, administrator, system, room, and user deletion sources
explicitly so cascade ownership and audit history remain deterministic.
- Release email and OAuth identity occupancy during deletion;
restoration reclaims available identities and reports conflicts
according to restore options.
- Preserve recoverable aggregate rows during the retention window,
remove ephemeral membership and playback state immediately, and purge
expired data in dependency order.
- Wait for realtime observation acknowledgements before publishing
lifecycle changes to avoid subscription registration races.
## Verification
- `make nextest`: 6707 passed, 0 skipped
- `make clippy`: passed for the workspace and all targets
- `make clippy-check`: passed
- `cargo fmt --all -- --check`: passed
- `git diff --check`: passed
2 months ago
|
|
|
let retained = sqlx::query!(
|
|
|
|
|
r#"SELECT deleted_at,
|
|
|
|
|
deletion_source AS "deletion_source?: DeletionSource"
|
|
|
|
|
FROM media WHERE id = $1"#,
|
|
|
|
|
created.id.as_i64(),
|
|
|
|
|
)
|
|
|
|
|
.fetch_one(&ctx.pool)
|
|
|
|
|
.await
|
|
|
|
|
.checked("soft-deleted media row should remain in storage");
|
|
|
|
|
assert!(retained.deleted_at.is_some());
|
|
|
|
|
assert_eq!(retained.deletion_source, Some(DeletionSource::User));
|
|
|
|
|
|
|
|
|
|
// Double delete returns false
|
|
|
|
|
let deleted_again = media_repo
|
|
|
|
|
.delete(&created.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert!(!deleted_again);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_duplicate_media_names_are_allowed_in_same_playlist() {
|
|
|
|
|
let ctx = setup_test_context("5").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"same_name.mp4",
|
|
|
|
|
0,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let duplicate = make_media(&ctx.root_playlist.id, &ctx.room.id, "same_name.mp4", 1);
|
|
|
|
|
let created = media_repo
|
|
|
|
|
.create(&duplicate)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert_eq!(created.name, "same_name.mp4");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_duplicate_media_positions_are_allowed() {
|
|
|
|
|
let ctx = setup_test_context("6").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"first.mp4",
|
|
|
|
|
0,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
// Duplicate floating positions are allowed; ordering falls back to the
|
|
|
|
|
// secondary sort key and move operations rebalance only when needed.
|
|
|
|
|
let duplicate = make_media(&ctx.root_playlist.id, &ctx.room.id, "second.mp4", 0);
|
|
|
|
|
let result = media_repo
|
|
|
|
|
.create(&duplicate)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let items = media_repo
|
|
|
|
|
.get_by_playlist(&ctx.root_playlist.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert_eq!(result.name, "second.mp4");
|
|
|
|
|
assert_eq!(items.len(), 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_media_get_by_playlist() {
|
|
|
|
|
let ctx = setup_test_context("7").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(&ctx.root_playlist.id, &ctx.room.id, "a.mp4", 0))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(&ctx.root_playlist.id, &ctx.room.id, "b.mp4", 1))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(&ctx.root_playlist.id, &ctx.room.id, "c.mp4", 2))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let items = media_repo
|
|
|
|
|
.get_by_playlist(&ctx.root_playlist.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert_eq!(items.len(), 3);
|
|
|
|
|
assert_eq!(items[0].name, "a.mp4");
|
|
|
|
|
assert_eq!(items[1].name, "b.mp4");
|
|
|
|
|
assert_eq!(items[2].name, "c.mp4");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_media_can_exist_at_room_root_without_playlist() {
|
|
|
|
|
let (_container, pool) = create_test_pool().await;
|
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
|
|
|
|
|
|
|
|
let owner = user_repo
|
|
|
|
|
.create(&make_user("media_root_owner"))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let room = room_repo
|
|
|
|
|
.create(&{
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
Room {
|
|
|
|
|
id: RoomId::new(),
|
|
|
|
|
name: "Media Root Room".to_string(),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
category: None,
|
|
|
|
|
labels: Vec::new(),
|
|
|
|
|
created_by: owner.id,
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let media_id = MediaId::new();
|
|
|
|
|
sqlx::query!(
|
|
|
|
|
"INSERT INTO media (
|
|
|
|
|
id, playlist_id, room_id, creator_id, name, position,
|
|
|
|
|
source_provider, source_config, provider_instance_name, added_at, updated_at, version
|
|
|
|
|
) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW(), 0)",
|
|
|
|
|
media_id.as_i64(),
|
|
|
|
|
room.id.as_i64(),
|
|
|
|
|
owner.id.as_i64(),
|
|
|
|
|
"root-media.mp4",
|
|
|
|
|
0.0,
|
|
|
|
|
ProviderType::DirectUrl.as_i16(),
|
|
|
|
|
synctv_core_testing::media_source_config_json(
|
|
|
|
|
synctv_core_testing::direct_url_media_source_config("https://example.com/root.mp4"),
|
|
|
|
|
),
|
|
|
|
|
Option::<String>::None
|
|
|
|
|
)
|
|
|
|
|
.execute(&pool)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let fetched = media_repo
|
|
|
|
|
.get_by_id(&media_id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed")
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert!(fetched.playlist_id.is_none());
|
|
|
|
|
|
|
|
|
|
let items = media_repo
|
|
|
|
|
.get_room_root(&room.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert_eq!(items.len(), 1);
|
|
|
|
|
assert_eq!(items[0].id, media_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_media_cascade_delete_with_playlist() {
|
|
|
|
|
let ctx = setup_test_context("8").await;
|
|
|
|
|
let playlist_repo = PlaylistRepository::new(ctx.pool.clone());
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let child_playlist = playlist_repo
|
|
|
|
|
.create(&Playlist {
|
|
|
|
|
id: PlaylistId::new(),
|
|
|
|
|
room_id: ctx.room.id,
|
|
|
|
|
creator_id: None,
|
|
|
|
|
browse_access_mode: synctv_core::models::PlaylistBrowseAccessMode::Default,
|
|
|
|
|
name: "Child PL".to_string(),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
parent_id: Some(ctx.root_playlist.id),
|
|
|
|
|
position: 0.0,
|
|
|
|
|
source_provider: None,
|
|
|
|
|
source_config: None,
|
|
|
|
|
provider_instance_name: None,
|
|
|
|
|
created_at: Utc::now(),
|
|
|
|
|
updated_at: Utc::now(),
|
|
|
|
|
version: 0,
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
// Add media to child playlist
|
|
|
|
|
let media = media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&child_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"cascade_test.mp4",
|
|
|
|
|
0,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let room_service = create_test_room_service(ctx.pool.clone());
|
|
|
|
|
room_service
|
|
|
|
|
.delete_entries(
|
|
|
|
|
ctx.room.id,
|
|
|
|
|
ctx.owner.id,
|
|
|
|
|
DeleteEntriesRequest {
|
|
|
|
|
playlist_ids: vec![child_playlist.id],
|
|
|
|
|
media_ids: Vec::new(),
|
|
|
|
|
force: false,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let fetched = media_repo
|
|
|
|
|
.get_by_id(&media.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert!(
|
|
|
|
|
fetched.is_none(),
|
|
|
|
|
"Media should be deleted when its playlist subtree is deleted"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_media_move_with_tx_reorders_scope() {
|
|
|
|
|
let ctx = setup_test_context("9").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let m1 = media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"first.mp4",
|
|
|
|
|
1024,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let m2 = media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"second.mp4",
|
|
|
|
|
2048,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let mut tx = ctx
|
|
|
|
|
.pool
|
|
|
|
|
.begin()
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
media_repo
|
|
|
|
|
.move_with_tx(&ctx.room.id, &m2.id, Some(&m1.id), None, &mut tx)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
tx.commit().await.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let updated_m1 = media_repo
|
|
|
|
|
.get_by_id(&m1.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed")
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let updated_m2 = media_repo
|
|
|
|
|
.get_by_id(&m2.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed")
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
assert!(updated_m2.position < updated_m1.position);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_media_count_by_playlist() {
|
|
|
|
|
let ctx = setup_test_context("10").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
media_repo
|
|
|
|
|
.count_by_playlist(&ctx.root_playlist.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed"),
|
|
|
|
|
0
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(&ctx.root_playlist.id, &ctx.room.id, "a.mp4", 0))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(&ctx.root_playlist.id, &ctx.room.id, "b.mp4", 1))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
media_repo
|
|
|
|
|
.count_by_playlist(&ctx.root_playlist.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed"),
|
|
|
|
|
2
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_media_batch_delete() {
|
|
|
|
|
let ctx = setup_test_context("11").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let m1 = media_repo
|
|
|
|
|
.create(&make_media(&ctx.root_playlist.id, &ctx.room.id, "a.mp4", 0))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let m2 = media_repo
|
|
|
|
|
.create(&make_media(&ctx.root_playlist.id, &ctx.room.id, "b.mp4", 1))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let m3 = media_repo
|
|
|
|
|
.create(&make_media(&ctx.root_playlist.id, &ctx.room.id, "c.mp4", 2))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
// Batch delete first two
|
|
|
|
|
let deleted_count = media_repo
|
|
|
|
|
.delete_batch(&[m1.id, m2.id])
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert_eq!(deleted_count, 2);
|
|
|
|
|
|
|
|
|
|
// Only m3 should remain
|
|
|
|
|
assert!(media_repo
|
|
|
|
|
.get_by_id(&m1.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed")
|
|
|
|
|
.is_none());
|
|
|
|
|
assert!(media_repo
|
|
|
|
|
.get_by_id(&m2.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed")
|
|
|
|
|
.is_none());
|
|
|
|
|
assert!(media_repo
|
|
|
|
|
.get_by_id(&m3.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed")
|
|
|
|
|
.is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Append-position helper tests
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_get_next_append_position_with_tx_empty_playlist_returns_order_step() {
|
|
|
|
|
let ctx = setup_test_context("next_pos_empty").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let mut tx = ctx
|
|
|
|
|
.pool
|
|
|
|
|
.begin()
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let next_pos = media_repo
|
|
|
|
|
.get_next_append_position_with_tx(&ctx.room.id, Some(&ctx.root_playlist.id), &mut tx)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
tx.commit().await.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
assert_f64_eq(next_pos, 1024.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_get_next_append_position_with_tx_existing_items() {
|
|
|
|
|
let ctx = setup_test_context("next_pos_existing").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(&ctx.root_playlist.id, &ctx.room.id, "a.mp4", 0))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"b.mp4",
|
|
|
|
|
2048,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"c.mp4",
|
|
|
|
|
4096,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let mut tx = ctx
|
|
|
|
|
.pool
|
|
|
|
|
.begin()
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let next_pos = media_repo
|
|
|
|
|
.get_next_append_position_with_tx(&ctx.room.id, Some(&ctx.root_playlist.id), &mut tx)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
tx.commit().await.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
assert_f64_eq(next_pos, 5120.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// create_batch_chunked test
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_create_batch_chunked_inserts_all() {
|
|
|
|
|
let ctx = setup_test_context("batch_create").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let items: Vec<Media> = (0..5)
|
|
|
|
|
.map(|i| {
|
|
|
|
|
make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
&format!("batch_{i}.mp4"),
|
|
|
|
|
i,
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let results = media_repo
|
|
|
|
|
.create_batch(&items)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert_eq!(results.len(), 5);
|
|
|
|
|
|
|
|
|
|
let playlist_items = media_repo
|
|
|
|
|
.get_by_playlist(&ctx.root_playlist.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert_eq!(playlist_items.len(), 5);
|
|
|
|
|
for (i, item) in playlist_items.iter().enumerate() {
|
|
|
|
|
assert_f64_eq(item.position, usize_to_f64(i));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// count_by_playlists_batch tests
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_count_by_playlists_batch_multiple_playlists() {
|
|
|
|
|
let ctx = setup_test_context("batch_count").await;
|
|
|
|
|
let playlist_repo = PlaylistRepository::new(ctx.pool.clone());
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let pl2 = playlist_repo
|
|
|
|
|
.create(&Playlist {
|
|
|
|
|
id: PlaylistId::new(),
|
|
|
|
|
room_id: ctx.room.id,
|
|
|
|
|
creator_id: None,
|
|
|
|
|
browse_access_mode: synctv_core::models::PlaylistBrowseAccessMode::Default,
|
|
|
|
|
name: "Second PL".to_string(),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
parent_id: Some(ctx.root_playlist.id),
|
|
|
|
|
position: 0.0,
|
|
|
|
|
source_provider: None,
|
|
|
|
|
source_config: None,
|
|
|
|
|
provider_instance_name: None,
|
|
|
|
|
created_at: Utc::now(),
|
|
|
|
|
updated_at: Utc::now(),
|
|
|
|
|
version: 0,
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
// Add 3 items to root, 2 to pl2
|
|
|
|
|
for i in 0..3 {
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
&format!("root_{i}.mp4"),
|
|
|
|
|
i,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
}
|
|
|
|
|
for i in 0..2 {
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&pl2.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
&format!("pl2_{i}.mp4"),
|
|
|
|
|
i,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let counts = media_repo
|
|
|
|
|
.count_by_playlists_batch(&[ctx.root_playlist.id, pl2.id])
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
counts.get(&ctx.root_playlist.id),
|
|
|
|
|
Some(&3),
|
|
|
|
|
"Root playlist should have 3 items"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
counts.get(&pl2.id),
|
|
|
|
|
Some(&2),
|
|
|
|
|
"Second playlist should have 2 items"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_count_by_playlists_batch_empty_input() {
|
|
|
|
|
let ctx = setup_test_context("batch_count_empty").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let counts = media_repo
|
|
|
|
|
.count_by_playlists_batch(&[])
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert!(counts.is_empty(), "Empty input should return empty map");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// get_by_ids test
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_get_by_ids_partial_returns_subset() {
|
|
|
|
|
let ctx = setup_test_context("get_by_ids").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let m1 = media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"ids_a.mp4",
|
|
|
|
|
0,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let m2 = media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"ids_b.mp4",
|
|
|
|
|
1,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let _m3 = media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"ids_c.mp4",
|
|
|
|
|
2,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
// Ask for only m1 and m2 (not m3), plus a nonexistent ID
|
|
|
|
|
let nonexistent = MediaId::new();
|
|
|
|
|
let results = media_repo
|
|
|
|
|
.get_by_ids(&[m1.id, m2.id, nonexistent])
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
assert_eq!(results.len(), 2, "Should return only the 2 existing items");
|
|
|
|
|
let result_ids: std::collections::HashSet<MediaId> = results.iter().map(|m| m.id).collect();
|
|
|
|
|
assert!(result_ids.contains(&m1.id));
|
|
|
|
|
assert!(result_ids.contains(&m2.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// delete_by_playlist test
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_delete_by_playlist_removes_all() {
|
|
|
|
|
let ctx = setup_test_context("del_by_pl").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"del_a.mp4",
|
|
|
|
|
0,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"del_b.mp4",
|
|
|
|
|
1,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
"del_c.mp4",
|
|
|
|
|
2,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
media_repo
|
|
|
|
|
.count_by_playlist(&ctx.root_playlist.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed"),
|
|
|
|
|
3
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let deleted = media_repo
|
|
|
|
|
.delete_playlist(&ctx.root_playlist.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert_eq!(deleted, 3);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
media_repo
|
|
|
|
|
.count_by_playlist(&ctx.root_playlist.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed"),
|
|
|
|
|
0
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_delete_room_root_only_removes_target_room_media() {
|
|
|
|
|
let ctx = setup_test_context("del_root").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
let user_repo = UserRepository::new(ctx.pool.clone());
|
|
|
|
|
let room_repo = RoomRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let other_owner = user_repo
|
|
|
|
|
.create(&make_user("media_owner_del_root_other"))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let other_room = room_repo
|
|
|
|
|
.create(&{
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
Room {
|
|
|
|
|
id: RoomId::new(),
|
|
|
|
|
name: "Other root media room".to_string(),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
category: None,
|
|
|
|
|
labels: Vec::new(),
|
|
|
|
|
created_by: other_owner.id,
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_room_root_media(&ctx.room.id, "room-a-root-1.mp4", 0))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_room_root_media(&ctx.room.id, "room-a-root-2.mp4", 1))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_room_root_media(
|
|
|
|
|
&other_room.id,
|
|
|
|
|
"room-b-root-1.mp4",
|
|
|
|
|
0,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
media_repo
|
|
|
|
|
.count_room_root(&ctx.room.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed"),
|
|
|
|
|
2
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
media_repo
|
|
|
|
|
.count_room_root(&other_room.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed"),
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let deleted = media_repo
|
|
|
|
|
.delete_room_root(&ctx.room.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
assert_eq!(deleted, 2);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
media_repo
|
|
|
|
|
.count_room_root(&ctx.room.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed"),
|
|
|
|
|
0
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
media_repo
|
|
|
|
|
.count_room_root(&other_room.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed"),
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Concurrent append-position assignment tests
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_concurrent_add_to_empty_playlist_unique_positions() {
|
|
|
|
|
let ctx = setup_test_context("concurrent_empty").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
let num_concurrent = 10;
|
|
|
|
|
let mut handles = Vec::new();
|
|
|
|
|
|
|
|
|
|
// Spawn 10 concurrent tasks, each adding media to the same empty playlist
|
|
|
|
|
for i in 0..num_concurrent {
|
|
|
|
|
let pool = ctx.pool.clone();
|
|
|
|
|
let playlist_id = ctx.root_playlist.id;
|
|
|
|
|
let room_id = ctx.room.id;
|
|
|
|
|
let media_repo = media_repo.clone();
|
|
|
|
|
|
|
|
|
|
let handle = tokio::spawn(async move {
|
|
|
|
|
let mut tx = pool.begin().await.checked("Failed to begin transaction");
|
|
|
|
|
|
|
|
|
|
let position = media_repo
|
|
|
|
|
.get_next_append_position_with_tx(&room_id, Some(&playlist_id), &mut tx)
|
|
|
|
|
.await
|
|
|
|
|
.checked("Failed to get next position");
|
|
|
|
|
|
|
|
|
|
let media = Media {
|
|
|
|
|
id: MediaId::new(),
|
|
|
|
|
playlist_id: Some(playlist_id),
|
|
|
|
|
room_id,
|
|
|
|
|
creator_id: None,
|
|
|
|
|
name: format!("concurrent_{i}.mp4"),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
position,
|
|
|
|
|
source_provider: SourceProvider::DirectUrl,
|
|
|
|
|
source_config: synctv_core_testing::direct_url_media_source_config(format!(
|
|
|
|
|
"https://example.com/video{i}.mp4"
|
|
|
|
|
)),
|
|
|
|
|
provider_instance_name: None,
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
thumbnail_file_reference_id: None,
|
|
|
|
|
added_at: Utc::now(),
|
|
|
|
|
updated_at: Utc::now(),
|
|
|
|
|
version: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let result = media_repo.create_with_executor(&media, &mut *tx).await;
|
|
|
|
|
|
|
|
|
|
tx.commit().await.checked("Failed to commit transaction");
|
|
|
|
|
|
|
|
|
|
result.map(|m| m.position)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
handles.push(handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let successful_positions: Vec<f64> = futures::future::join_all(handles)
|
|
|
|
|
.await
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|r| r.checked("Task panicked"))
|
|
|
|
|
.map(|r| r.checked("concurrent media insert should succeed"))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
successful_positions.len(),
|
|
|
|
|
num_concurrent,
|
|
|
|
|
"All {} concurrent adds should succeed, but only {} did",
|
|
|
|
|
num_concurrent,
|
|
|
|
|
successful_positions.len()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// All positions should be unique
|
|
|
|
|
let mut sorted_positions = successful_positions.clone();
|
|
|
|
|
sorted_positions.sort_by(f64::total_cmp);
|
|
|
|
|
sorted_positions.dedup();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
sorted_positions.len(),
|
|
|
|
|
num_concurrent,
|
|
|
|
|
"All positions should be unique, got duplicates: {successful_positions:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let expected_positions: Vec<f64> = (1..=num_concurrent)
|
|
|
|
|
.map(|n| usize_to_f64(n) * 1024.0)
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
sorted_positions.len(),
|
|
|
|
|
expected_positions.len(),
|
|
|
|
|
"Positions should follow sparse append ordering"
|
|
|
|
|
);
|
|
|
|
|
for (actual, expected) in sorted_positions.iter().zip(expected_positions) {
|
|
|
|
|
assert_f64_eq(*actual, expected);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_concurrent_add_to_nonempty_playlist_unique_positions() {
|
|
|
|
|
let ctx = setup_test_context("concurrent_nonempty").await;
|
|
|
|
|
let media_repo = MediaRepository::new(ctx.pool.clone());
|
|
|
|
|
|
|
|
|
|
// Pre-populate with 5 existing items
|
|
|
|
|
for i in 0..5 {
|
|
|
|
|
media_repo
|
|
|
|
|
.create(&make_media(
|
|
|
|
|
&ctx.root_playlist.id,
|
|
|
|
|
&ctx.room.id,
|
|
|
|
|
&format!("existing_{i}.mp4"),
|
|
|
|
|
i,
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let num_concurrent = 5;
|
|
|
|
|
let mut handles = Vec::new();
|
|
|
|
|
|
|
|
|
|
// Spawn 5 concurrent tasks adding more media
|
|
|
|
|
for i in 0..num_concurrent {
|
|
|
|
|
let pool = ctx.pool.clone();
|
|
|
|
|
let playlist_id = ctx.root_playlist.id;
|
|
|
|
|
let room_id = ctx.room.id;
|
|
|
|
|
let media_repo = media_repo.clone();
|
|
|
|
|
|
|
|
|
|
let handle = tokio::spawn(async move {
|
|
|
|
|
let mut tx = pool.begin().await.checked("Failed to begin transaction");
|
|
|
|
|
|
|
|
|
|
let position = media_repo
|
|
|
|
|
.get_next_append_position_with_tx(&room_id, Some(&playlist_id), &mut tx)
|
|
|
|
|
.await
|
|
|
|
|
.checked("Failed to get next position");
|
|
|
|
|
|
|
|
|
|
let media = Media {
|
|
|
|
|
id: MediaId::new(),
|
|
|
|
|
playlist_id: Some(playlist_id),
|
|
|
|
|
room_id,
|
|
|
|
|
creator_id: None,
|
|
|
|
|
name: format!("new_{i}.mp4"),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
position,
|
|
|
|
|
source_provider: SourceProvider::DirectUrl,
|
|
|
|
|
source_config: synctv_core_testing::direct_url_media_source_config(format!(
|
|
|
|
|
"https://example.com/new{i}.mp4"
|
|
|
|
|
)),
|
|
|
|
|
provider_instance_name: None,
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
thumbnail_file_reference_id: None,
|
|
|
|
|
added_at: Utc::now(),
|
|
|
|
|
updated_at: Utc::now(),
|
|
|
|
|
version: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let result = media_repo.create_with_executor(&media, &mut *tx).await;
|
|
|
|
|
tx.commit().await.checked("Failed to commit transaction");
|
|
|
|
|
|
|
|
|
|
result.map(|m| m.position)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
handles.push(handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let successful_positions: Vec<f64> = futures::future::join_all(handles)
|
|
|
|
|
.await
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|r| r.checked("Task panicked"))
|
|
|
|
|
.map(|r| r.checked("concurrent media insert should succeed"))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
successful_positions.len(),
|
|
|
|
|
num_concurrent,
|
|
|
|
|
"All {num_concurrent} concurrent adds should succeed"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// All new positions should be unique and greater than the existing max.
|
|
|
|
|
let mut sorted_positions = successful_positions;
|
|
|
|
|
sorted_positions.sort_by(f64::total_cmp);
|
|
|
|
|
sorted_positions.dedup();
|
|
|
|
|
assert_eq!(sorted_positions.len(), num_concurrent);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
sorted_positions.iter().all(|&pos| pos > 4.0),
|
|
|
|
|
"All new positions should append after the existing max position"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_media_rejects_cross_room_playlist_reference() {
|
|
|
|
|
let (_container, pool) = create_test_pool().await;
|
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
|
let playlist_repo = PlaylistRepository::new(pool.clone());
|
|
|
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
|
|
|
|
|
|
|
|
let owner = user_repo
|
|
|
|
|
.create(&make_user("cross_room_media_owner"))
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let room_a = room_repo
|
|
|
|
|
.create(&{
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
Room {
|
|
|
|
|
id: RoomId::new(),
|
|
|
|
|
name: "Cross Room Media A".to_string(),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
category: None,
|
|
|
|
|
labels: Vec::new(),
|
|
|
|
|
created_by: owner.id,
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
let room_b = room_repo
|
|
|
|
|
.create(&{
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
Room {
|
|
|
|
|
id: RoomId::new(),
|
|
|
|
|
name: "Cross Room Media B".to_string(),
|
|
|
|
|
description: String::new(),
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
category: None,
|
|
|
|
|
labels: Vec::new(),
|
|
|
|
|
created_by: owner.id,
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let playlist_b = playlist_repo
|
|
|
|
|
.create(&Playlist {
|
|
|
|
|
id: PlaylistId::new(),
|
|
|
|
|
room_id: room_b.id,
|
|
|
|
|
creator_id: Some(owner.id),
|
|
|
|
|
browse_access_mode: synctv_core::models::PlaylistBrowseAccessMode::Default,
|
|
|
|
|
name: "Room B Playlist".to_string(),
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.checked("test operation should succeed");
|
|
|
|
|
|
|
|
|
|
let result = media_repo
|
|
|
|
|
.create(&Media {
|
|
|
|
|
id: MediaId::new(),
|
|
|
|
|
playlist_id: Some(playlist_b.id),
|
|
|
|
|
room_id: room_a.id,
|
|
|
|
|
creator_id: None,
|
|
|
|
|
name: "cross-room.mp4".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/cross-room.mp4",
|
|
|
|
|
),
|
|
|
|
|
provider_instance_name: None,
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
thumbnail_file_reference_id: None,
|
|
|
|
|
added_at: Utc::now(),
|
|
|
|
|
updated_at: Utc::now(),
|
|
|
|
|
version: 0,
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
result.is_err(),
|
|
|
|
|
"media must not be insertable when playlist_id belongs to a different room"
|
|
|
|
|
);
|
|
|
|
|
}
|