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/media_optimistic_lock_tests.rs

531 lines
16 KiB
Rust

//! Media optimistic locking integration tests
//!
//! Tests for version-based optimistic locking in `MediaRepository`.
//!
use chrono::Utc;
use sqlx::PgPool;
use synctv_core::{
models::{
Media, MediaId, MediaSourceConfig, Playlist, PlaylistId, Room, RoomId, RoomStatus,
SourceProvider, User, UserId, UserRole, UserStatus,
},
repository::{MediaRepository, PlaylistRepository, RoomRepository, UserRepository},
};
use synctv_core_testing::{create_test_pool, ok, some, TestContainer};
fn direct_url_first_media_url(source_config: &MediaSourceConfig) -> &str {
let MediaSourceConfig::DirectUrl(config) = source_config else {
panic!("expected DirectUrl source_config");
};
config.medias.first().map_or_else(
|| panic!("expected DirectUrl media resource"),
|media| media.url.as_str(),
)
}
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 playlist_repo = PlaylistRepository::new(pool.clone());
let owner = ok(
user_repo
.create(&make_user(&format!("optlock_owner_{suffix}")))
.await,
"optimistic lock owner should be created",
);
let room = ok(
room_repo
.create(&{
let now = Utc::now();
Room {
id: RoomId::new(),
name: format!("OptLock 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,
"optimistic lock room should be created",
);
let root_playlist = ok(
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: 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,
})
.await,
"optimistic lock root playlist should be created",
);
TestContext {
_container: container,
pool,
_owner: 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,
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_with_version_succeeds_when_version_matches() {
let ctx = setup_test_context("v1").await;
let media_repo = MediaRepository::new(ctx.pool.clone());
let media = ok(
media_repo
.create(&make_media(
&ctx.root_playlist.id,
&ctx.room.id,
"version_test.mp4",
0,
))
.await,
"media should be created",
);
// Initial version should be 0
assert_eq!(media.version, 0, "New media should have version 0");
// Update with matching version should succeed
let mut updated = media.clone();
updated.name = "version_test_updated.mp4".to_string();
let result = ok(
media_repo
.update_with_version(&updated, media.version)
.await,
"media update with matching version should succeed",
);
assert!(
result.is_some(),
"Update should succeed when version matches"
);
let result = some(
result,
"media update with matching version should return media",
);
assert_eq!(result.name, "version_test_updated.mp4");
assert_eq!(result.version, 1, "Version should be incremented to 1");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_with_version_conflict_when_version_mismatch() {
let ctx = setup_test_context("v2").await;
let media_repo = MediaRepository::new(ctx.pool.clone());
let media = ok(
media_repo
.create(&make_media(
&ctx.root_playlist.id,
&ctx.room.id,
"conflict_test.mp4",
0,
))
.await,
"media should be created",
);
// First update succeeds, version becomes 1
let mut first_update = media.clone();
first_update.name = "first_update.mp4".to_string();
let first_result = some(
ok(
media_repo.update_with_version(&first_update, 0).await,
"first media update should succeed",
),
"first media update should return media",
);
assert_eq!(first_result.version, 1);
// Try to update with stale version 0 - should fail with conflict
let mut stale_update = media.clone();
stale_update.name = "stale_update.mp4".to_string();
let result = ok(
media_repo.update_with_version(&stale_update, 0).await,
"stale media update should be evaluated",
);
assert!(
result.is_none(),
"Update with stale version should return None"
);
// Verify the data wasn't corrupted
let current = some(
ok(
media_repo.get_by_id(&media.id).await,
"current media should be fetched",
),
"current media should exist",
);
assert_eq!(
current.name, "first_update.mp4",
"Name should be from first update"
);
assert_eq!(current.version, 1, "Version should still be 1");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_concurrent_updates_detect_conflict() {
let ctx = setup_test_context("v3").await;
let media_repo = MediaRepository::new(ctx.pool.clone());
let media = ok(
media_repo
.create(&make_media(
&ctx.root_playlist.id,
&ctx.room.id,
"concurrent.mp4",
0,
))
.await,
"media should be created",
);
// Simulate two clients reading the same data
let client1_media = media.clone();
let client2_media = media.clone();
// Client 1 updates first with version 0 -> succeeds, version becomes 1
let mut c1_update = client1_media.clone();
c1_update.name = "client1_update.mp4".to_string();
c1_update.position = 1.0;
let c1_result = some(
ok(
media_repo.update_with_version(&c1_update, 0).await,
"first concurrent media update should succeed",
),
"first concurrent media update should return media",
);
assert_eq!(c1_result.version, 1);
// Client 2 tries to update with same version 0 -> should fail
// because version is now 1
let mut c2_update = client2_media.clone();
c2_update.name = "client2_update.mp4".to_string();
c2_update.position = 2.0;
let c2_result = ok(
media_repo.update_with_version(&c2_update, 0).await,
"stale concurrent media update should be evaluated",
);
assert!(
c2_result.is_none(),
"Client 2 update should fail with version conflict"
);
// Verify only client1's changes persisted
let current = some(
ok(
media_repo.get_by_id(&media.id).await,
"current media should be fetched",
),
"current media should exist",
);
assert_eq!(current.name, "client1_update.mp4");
assert!((current.position - 1.0).abs() < f64::EPSILON);
assert_eq!(
direct_url_first_media_url(&current.source_config),
"https://example.com/video.mp4",
"the stale update cannot overwrite source_config"
);
assert_eq!(current.version, 1);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_sequential_updates_increment_version() {
let ctx = setup_test_context("v4").await;
let media_repo = MediaRepository::new(ctx.pool.clone());
let mut media = ok(
media_repo
.create(&make_media(
&ctx.root_playlist.id,
&ctx.room.id,
"sequential.mp4",
0,
))
.await,
"media should be created",
);
assert_eq!(media.version, 0);
// Update 1: version 0 -> 1
media.name = "update1.mp4".to_string();
media = some(
ok(
media_repo.update_with_version(&media, 0).await,
"first sequential media update should succeed",
),
"first sequential media update should return media",
);
assert_eq!(media.version, 1);
// Update 2: version 1 -> 2
media.name = "update2.mp4".to_string();
media = some(
ok(
media_repo.update_with_version(&media, 1).await,
"second sequential media update should succeed",
),
"second sequential media update should return media",
);
assert_eq!(media.version, 2);
// Update 3: version 2 -> 3
media.name = "update3.mp4".to_string();
media = some(
ok(
media_repo.update_with_version(&media, 2).await,
"third sequential media update should succeed",
),
"third sequential media update should return media",
);
assert_eq!(media.version, 3);
// Stale update with version 1 should fail (current is 3)
let mut stale = media.clone();
stale.name = "stale.mp4".to_string();
let result = ok(
media_repo.update_with_version(&stale, 1).await,
"stale sequential media update should be evaluated",
);
assert!(result.is_none(), "Stale update should fail");
// Correct update with version 3 should succeed
media.name = "update4.mp4".to_string();
media = some(
ok(
media_repo.update_with_version(&media, 3).await,
"fourth sequential media update should succeed",
),
"fourth sequential media update should return media",
);
assert_eq!(media.version, 4);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_with_version_replaces_source_config() {
let ctx = setup_test_context("v5").await;
let media_repo = MediaRepository::new(ctx.pool.clone());
let media = ok(
media_repo
.create(&make_media(
&ctx.root_playlist.id,
&ctx.room.id,
"config_test.mp4",
0,
))
.await,
"media should be created",
);
// Optimistic updates atomically replace the complete source configuration.
let mut updated = media.clone();
updated.name = "config_test_updated.mp4".to_string();
updated.source_config =
synctv_core_testing::direct_url_media_source_config("https://example.com/new.mp4");
let result = some(
ok(
media_repo.update_with_version(&updated, 0).await,
"metadata media update should succeed",
),
"metadata media update should return media",
);
assert_eq!(result.version, 1);
assert_eq!(result.name, "config_test_updated.mp4");
assert_eq!(
direct_url_first_media_url(&result.source_config),
"https://example.com/new.mp4"
);
// Concurrent update with old version should fail
let mut stale = media.clone();
stale.name = "stale_config_test.mp4".to_string();
stale.source_config =
synctv_core_testing::direct_url_media_source_config("https://example.com/stale.mp4");
let stale_result = ok(
media_repo.update_with_version(&stale, 0).await,
"stale metadata media update should be evaluated",
);
assert!(stale_result.is_none(), "Stale metadata update should fail");
let persisted = some(
ok(
media_repo.get_by_id(&media.id).await,
"updated media should be fetched after the stale update",
),
"updated media should still exist",
);
assert_eq!(
direct_url_first_media_url(&persisted.source_config),
"https://example.com/new.mp4"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_update_with_version_nonexistent_media() {
let ctx = setup_test_context("v6").await;
let media_repo = MediaRepository::new(ctx.pool.clone());
let missing_media = make_media(&ctx.root_playlist.id, &ctx.room.id, "nonexistent.mp4", 0);
let result = ok(
media_repo.update_with_version(&missing_media, 0).await,
"nonexistent media update should be evaluated",
);
assert!(
result.is_none(),
"Update on non-existent media should return None"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_version_returned_in_read_operations() {
let ctx = setup_test_context("v7").await;
let pool = ctx.pool.clone();
let media_repo = MediaRepository::new(pool);
let mut media = ok(
media_repo
.create(&make_media(
&ctx.root_playlist.id,
&ctx.room.id,
"read_ops.mp4",
0,
))
.await,
"media should be created",
);
assert_eq!(media.version, 0);
// Update to version 1
media.name = "read_ops_v1.mp4".to_string();
media = some(
ok(
media_repo.update_with_version(&media, 0).await,
"media update before read checks should succeed",
),
"media update before read checks should return media",
);
// get_by_id should return correct version
let by_id = some(
ok(
media_repo.get_by_id(&media.id).await,
"media should be fetched by id",
),
"media should exist by id",
);
assert_eq!(by_id.version, 1, "get_by_id should return version 1");
// get_by_playlist should return correct version
let by_playlist = ok(
media_repo.get_by_playlist(&ctx.root_playlist.id).await,
"media should be fetched by playlist",
);
assert_eq!(by_playlist.len(), 1);
assert_eq!(
by_playlist[0].version, 1,
"get_by_playlist should return version 1"
);
// get_by_ids should return correct version
let by_ids = ok(
media_repo.get_by_ids(&[media.id]).await,
"media should be fetched by ids",
);
assert_eq!(by_ids.len(), 1);
assert_eq!(by_ids[0].version, 1, "get_by_ids should return version 1");
}