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.
531 lines
16 KiB
Rust
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(¤t.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");
|
|
}
|