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.
1952 lines
62 KiB
Rust
1952 lines
62 KiB
Rust
//! `PlaybackService` integration tests
|
|
//!
|
|
//! Tests playback control including seek, speed, media switching, and
|
|
//! optimistic lock behavior with real `PostgreSQL` via testcontainers.
|
|
//!
|
|
|
|
use std::{sync::Arc, time::Duration};
|
|
|
|
use chrono::Utc;
|
|
use sqlx::PgPool;
|
|
use synctv_core::{
|
|
cache::{KeyBuilder, UsernameCache},
|
|
models::{
|
|
room::AutoPlaySettings, Media, MediaId, PlayMode, Playlist, ProviderTarget, SourceProvider,
|
|
User, UserId, UserRole, UserStatus,
|
|
},
|
|
repository::{MediaRepository, RoomPlaybackStateRepository, UserRepository},
|
|
service::{
|
|
BruteForceProtection, InMemoryTokenBlacklistStore, JwtService, RoomService, UserService,
|
|
},
|
|
Error,
|
|
};
|
|
use synctv_core_testing::create_test_pool;
|
|
use synctv_core_testing::{TestOptionExt, TestResultExt};
|
|
fn make_user_service(pool: &PgPool) -> UserService {
|
|
let secret = "Test_Secret_Key_For_JWT_Tokens_32Bytes!!";
|
|
let jwt_service = JwtService::new(secret).checked("JWT service should be created");
|
|
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_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,
|
|
}
|
|
}
|
|
|
|
async fn create_top_level_playlist(
|
|
pool: &PgPool,
|
|
room_id: &synctv_core::models::RoomId,
|
|
) -> Playlist {
|
|
let playlist = Playlist {
|
|
id: synctv_core::models::PlaylistId::new(),
|
|
room_id: *room_id,
|
|
creator_id: None,
|
|
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,
|
|
};
|
|
|
|
synctv_core::repository::PlaylistRepository::new(pool.clone())
|
|
.create(&playlist)
|
|
.await
|
|
.checked("top-level playlist should be created")
|
|
}
|
|
|
|
async fn attach_test_media(
|
|
pool: &PgPool,
|
|
room_id: synctv_core::models::RoomId,
|
|
owner_id: UserId,
|
|
) -> synctv_core::models::RoomPlaybackState {
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: None,
|
|
room_id,
|
|
creator_id: Some(owner_id),
|
|
name: "Playback Service Test Video".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,
|
|
cover_file_reference_id: None,
|
|
thumbnail_file_reference_id: None,
|
|
added_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
version: 0,
|
|
};
|
|
let media = MediaRepository::new(pool.clone())
|
|
.create(&media)
|
|
.await
|
|
.checked("test media should be created");
|
|
let playback_repo = RoomPlaybackStateRepository::new(pool.clone());
|
|
let mut state = playback_repo
|
|
.create_or_get(&room_id)
|
|
.await
|
|
.checked("playback state should be created");
|
|
state.playing_media_id = Some(media.id);
|
|
state.playing_playlist_id = None;
|
|
state.target = None;
|
|
state.position = 0.0;
|
|
playback_repo
|
|
.update(&state)
|
|
.await
|
|
.checked("playback state should attach test media")
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_seek_negative_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("seek_neg_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Seek Neg Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
|
|
let result = playback_service.seek(room.id, owner.id, -5.0).await;
|
|
|
|
assert!(result.is_err());
|
|
match result.failed("operation should fail") {
|
|
Error::InvalidInput(msg) => {
|
|
assert!(
|
|
msg.contains("non-negative") || msg.contains("negative"),
|
|
"Error should mention non-negative: {msg}"
|
|
);
|
|
}
|
|
other => std::panic::panic_any(format!("expected InvalidInput error, got: {other:?}")),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_seek_rejects_live_direct_url_source() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("seek_live_direct_url_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Seek Live Direct URL Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let mut source_config =
|
|
synctv_core_testing::direct_url_media_source_config("https://example.com/live.m3u8");
|
|
let synctv_core::models::MediaSourceConfig::DirectUrl(config) = &mut source_config else {
|
|
panic!("direct url source_config should be DirectUrl");
|
|
};
|
|
config.playback_kind = Some(synctv_core::models::PlaybackKind::Live);
|
|
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: None,
|
|
room_id: room.id,
|
|
creator_id: Some(owner.id),
|
|
name: "Live Direct URL".to_string(),
|
|
description: String::new(),
|
|
position: 0.0,
|
|
source_provider: SourceProvider::DirectUrl,
|
|
source_config,
|
|
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 media = media_repo
|
|
.create(&media)
|
|
.await
|
|
.checked("live media should be created");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
playback_service
|
|
.switch(room.id, owner.id, Some(media.id), None, None)
|
|
.await
|
|
.checked("live media should start playback");
|
|
|
|
let result = playback_service.seek(room.id, owner.id, 30.0).await;
|
|
|
|
match result.failed("live playback position update should fail") {
|
|
Error::InvalidInput(message) => {
|
|
assert!(
|
|
message.contains("live playback"),
|
|
"error should mention live playback: {message}"
|
|
);
|
|
}
|
|
other => std::panic::panic_any(format!("expected InvalidInput error, got: {other:?}")),
|
|
}
|
|
|
|
let state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("playback state should fetch");
|
|
assert!((state.position - 0.0).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_speed_zero_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("speed_zero_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Speed Zero Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
|
|
let result = playback_service.change_speed(room.id, owner.id, 0.0).await;
|
|
|
|
assert!(result.is_err());
|
|
match result.failed("operation should fail") {
|
|
Error::InvalidInput(msg) => {
|
|
assert!(
|
|
msg.contains("Speed") || msg.contains("speed"),
|
|
"Error should mention speed: {msg}"
|
|
);
|
|
}
|
|
other => std::panic::panic_any(format!("expected InvalidInput error, got: {other:?}")),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_speed_above_max_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("speed_max_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Speed Max Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
|
|
let result = playback_service.change_speed(room.id, owner.id, 17.0).await;
|
|
|
|
assert!(result.is_err());
|
|
match result.failed("operation should fail") {
|
|
Error::InvalidInput(msg) => {
|
|
assert!(
|
|
msg.contains("Speed") || msg.contains("16"),
|
|
"Error should mention speed limit: {msg}"
|
|
);
|
|
}
|
|
other => std::panic::panic_any(format!("expected InvalidInput error, got: {other:?}")),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_switch_media_resets_position() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("switch_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Switch Media Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playlist = create_top_level_playlist(&pool, &room.id).await;
|
|
|
|
// Add a media item
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: Some(playlist.id),
|
|
room_id: room.id,
|
|
creator_id: Some(owner.id),
|
|
name: "Test Video".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,
|
|
cover_file_reference_id: None,
|
|
thumbnail_file_reference_id: None,
|
|
added_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
version: 0,
|
|
};
|
|
let media = media_repo
|
|
.create(&media)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// First seek to a non-zero position
|
|
let playback_service = room_service.playback_service();
|
|
attach_test_media(&pool, room.id, owner.id).await;
|
|
playback_service
|
|
.seek(room.id, owner.id, 42.5)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Switch media should reset position to 0
|
|
let state = playback_service
|
|
.switch(room.id, owner.id, Some(media.id), None, None)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
assert!(
|
|
(state.position - 0.0).abs() < f64::EPSILON,
|
|
"Current time should be reset to 0 after media switch, got: {}",
|
|
state.position
|
|
);
|
|
assert_eq!(state.playing_media_id, Some(media.id));
|
|
assert!(
|
|
state.playing_playlist_id.is_none(),
|
|
"static media playback must not retain a playlist playback target"
|
|
);
|
|
assert!(state.is_playing, "Should be playing after media switch");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_switch_media_rejects_target() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("switch_media_relpath_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Switch Media Relative Path Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: None,
|
|
room_id: room.id,
|
|
creator_id: Some(owner.id),
|
|
name: "Standalone Video".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/standalone.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 media = media_repo
|
|
.create(&media)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let result = room_service
|
|
.playback_service()
|
|
.switch(
|
|
room.id,
|
|
owner.id,
|
|
Some(media.id),
|
|
None,
|
|
Some(ProviderTarget::alist("/unexpected".to_string())),
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(result, Err(Error::InvalidInput(_))));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_switch_media_validates_static_playlist_context() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("switch_static_context_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
let room = room_service
|
|
.create_room(
|
|
"Switch Static Context Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
let playlist = create_top_level_playlist(&pool, &room.id).await;
|
|
let other_playlist = create_top_level_playlist(&pool, &room.id).await;
|
|
let media = media_repo
|
|
.create(&Media {
|
|
id: MediaId::new(),
|
|
playlist_id: Some(playlist.id),
|
|
room_id: room.id,
|
|
creator_id: Some(owner.id),
|
|
name: "Static Context Video".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/static-context.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
|
|
.checked("test media should be created");
|
|
|
|
let state = room_service
|
|
.playback_service()
|
|
.switch(room.id, owner.id, Some(media.id), Some(playlist.id), None)
|
|
.await
|
|
.checked("matching static playlist context should be accepted");
|
|
assert_eq!(state.playing_media_id, Some(media.id));
|
|
assert_eq!(state.playing_playlist_id, Some(playlist.id));
|
|
assert!(state.target.is_none());
|
|
|
|
let result = room_service
|
|
.playback_service()
|
|
.switch(
|
|
room.id,
|
|
owner.id,
|
|
Some(media.id),
|
|
Some(other_playlist.id),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(matches!(result, Err(Error::InvalidInput(_))));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_switch_media_rejects_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 media_repo = MediaRepository::new(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("switch_inactive_creator_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Switch Inactive Creator Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let media_creator = user_repo
|
|
.create(&make_user("switch_inactive_media_creator"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
room_service
|
|
.join_room(room.id, media_creator.id, None)
|
|
.await
|
|
.checked("media creator should join the room before being banned");
|
|
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: None,
|
|
room_id: room.id,
|
|
creator_id: Some(media_creator.id),
|
|
name: "Inactive Creator Video".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/inactive.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 media = media_repo
|
|
.create(&media)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
user_repo
|
|
.ban(
|
|
&media_creator.id,
|
|
None,
|
|
Some("playback service test".to_string()),
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let result = room_service
|
|
.playback_service()
|
|
.switch(room.id, owner.id, Some(media.id), None, None)
|
|
.await;
|
|
|
|
match result.failed("media created by banned user must not be playable") {
|
|
Error::Authorization(message) => {
|
|
assert!(
|
|
message.contains("creator") && message.contains("unavailable"),
|
|
"error should explain creator availability: {message}"
|
|
);
|
|
}
|
|
other => std::panic::panic_any(format!("expected authorization error, got: {other:?}")),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_switch_media_serializes_with_concurrent_creator_ban() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
let playback_repo = RoomPlaybackStateRepository::new(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("switch_concurrent_ban_owner"))
|
|
.await
|
|
.checked("room owner should be created");
|
|
let creator = user_repo
|
|
.create(&make_user("switch_concurrent_ban_creator"))
|
|
.await
|
|
.checked("media creator should be created");
|
|
let room = room_service
|
|
.create_room(
|
|
"Switch Concurrent Creator Ban".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("room should be created");
|
|
room_service
|
|
.join_room(room.id, creator.id, None)
|
|
.await
|
|
.checked("media creator should join the room");
|
|
|
|
let media = media_repo
|
|
.create(&Media {
|
|
id: MediaId::new(),
|
|
playlist_id: None,
|
|
room_id: room.id,
|
|
creator_id: Some(creator.id),
|
|
name: "Concurrent Creator Ban Video".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/concurrent-creator-ban.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
|
|
.checked("media should be created");
|
|
playback_repo
|
|
.create_or_get(&room.id)
|
|
.await
|
|
.checked("empty playback state should be created");
|
|
|
|
let mut ban_tx = pool.begin().await.checked("ban transaction should begin");
|
|
user_repo
|
|
.get_by_id_for_update_with_executor(&creator.id, &mut *ban_tx)
|
|
.await
|
|
.checked("creator row should lock")
|
|
.checked("creator should exist");
|
|
user_repo
|
|
.insert_ban_with_executor(
|
|
&creator.id,
|
|
Some(&owner.id),
|
|
Some("concurrent playback test".to_string()),
|
|
&mut *ban_tx,
|
|
)
|
|
.await
|
|
.checked("ban should be staged");
|
|
|
|
let playback_service = room_service.playback_service().clone();
|
|
let switch_barrier = Arc::new(tokio::sync::Barrier::new(2));
|
|
let task_barrier = Arc::clone(&switch_barrier);
|
|
let mut switch_task = tokio::spawn(async move {
|
|
task_barrier.wait().await;
|
|
playback_service
|
|
.switch(room.id, owner.id, Some(media.id), None, None)
|
|
.await
|
|
});
|
|
switch_barrier.wait().await;
|
|
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(250), &mut switch_task)
|
|
.await
|
|
.is_err(),
|
|
"playback switch must wait for the in-flight creator ban"
|
|
);
|
|
|
|
ban_tx
|
|
.commit()
|
|
.await
|
|
.checked("ban transaction should commit");
|
|
let switch_error = tokio::time::timeout(Duration::from_secs(5), switch_task)
|
|
.await
|
|
.checked("playback switch should finish after the ban commits")
|
|
.checked("playback switch task should join")
|
|
.failed("banned creator media must not become the playback source");
|
|
assert!(matches!(switch_error, Error::Authorization(_)));
|
|
|
|
let state = playback_repo
|
|
.get(&room.id)
|
|
.await
|
|
.checked("playback state should load")
|
|
.checked("playback state should exist");
|
|
assert!(state.playing_media_id.is_none());
|
|
assert!(state.playing_playlist_id.is_none());
|
|
assert!(!state.is_playing);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_switch_media_serializes_with_concurrent_dynamic_ancestor_ban() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
let playlist_repo = synctv_core::repository::PlaylistRepository::new(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
let playback_repo = RoomPlaybackStateRepository::new(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("switch_ancestor_ban_owner"))
|
|
.await
|
|
.checked("room owner should be created");
|
|
let dynamic_creator = user_repo
|
|
.create(&make_user("switch_ancestor_ban_dynamic_creator"))
|
|
.await
|
|
.checked("dynamic playlist creator should be created");
|
|
let media_creator = user_repo
|
|
.create(&make_user("switch_ancestor_ban_media_creator"))
|
|
.await
|
|
.checked("media creator should be created");
|
|
let room = room_service
|
|
.create_room(
|
|
"Switch Concurrent Dynamic Ancestor Ban".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("room should be created");
|
|
for member_id in [dynamic_creator.id, media_creator.id] {
|
|
room_service
|
|
.join_room(room.id, member_id, None)
|
|
.await
|
|
.checked("resource creator should join the room");
|
|
}
|
|
|
|
let dynamic_playlist = playlist_repo
|
|
.create(&Playlist {
|
|
id: synctv_core::models::PlaylistId::new(),
|
|
room_id: room.id,
|
|
creator_id: Some(dynamic_creator.id),
|
|
browse_access_mode: synctv_core::models::PlaylistBrowseAccessMode::Default,
|
|
name: "Historical Dynamic Root".to_string(),
|
|
description: String::new(),
|
|
cover_file_reference_id: None,
|
|
parent_id: None,
|
|
position: 0.0,
|
|
source_provider: Some(SourceProvider::Alist),
|
|
source_config: Some(synctv_core_testing::alist_directory_playlist_source_config(
|
|
"alist-test",
|
|
"/historical",
|
|
)),
|
|
provider_instance_name: None,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
version: 0,
|
|
})
|
|
.await
|
|
.checked("dynamic playlist should be created");
|
|
let static_child = playlist_repo
|
|
.create(&Playlist {
|
|
id: synctv_core::models::PlaylistId::new(),
|
|
room_id: room.id,
|
|
creator_id: Some(media_creator.id),
|
|
browse_access_mode: synctv_core::models::PlaylistBrowseAccessMode::Default,
|
|
name: "Historical Static Child".to_string(),
|
|
description: String::new(),
|
|
cover_file_reference_id: None,
|
|
parent_id: Some(dynamic_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("historical static child should be created directly");
|
|
let media = media_repo
|
|
.create(&Media {
|
|
id: MediaId::new(),
|
|
playlist_id: Some(static_child.id),
|
|
room_id: room.id,
|
|
creator_id: Some(media_creator.id),
|
|
name: "Historical Nested Video".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/historical-nested.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
|
|
.checked("historical nested media should be created directly");
|
|
playback_repo
|
|
.create_or_get(&room.id)
|
|
.await
|
|
.checked("empty playback state should be created");
|
|
let media_id = media.id;
|
|
let static_child_id = static_child.id;
|
|
|
|
let mut ban_tx = pool.begin().await.checked("ban transaction should begin");
|
|
user_repo
|
|
.get_by_id_for_update_with_executor(&dynamic_creator.id, &mut *ban_tx)
|
|
.await
|
|
.checked("dynamic creator row should lock")
|
|
.checked("dynamic creator should exist");
|
|
user_repo
|
|
.insert_ban_with_executor(
|
|
&dynamic_creator.id,
|
|
Some(&owner.id),
|
|
Some("concurrent dynamic ancestor test".to_string()),
|
|
&mut *ban_tx,
|
|
)
|
|
.await
|
|
.checked("dynamic creator ban should be staged");
|
|
|
|
let playback_service = room_service.playback_service().clone();
|
|
let switch_barrier = Arc::new(tokio::sync::Barrier::new(2));
|
|
let task_barrier = Arc::clone(&switch_barrier);
|
|
let mut switch_task = tokio::spawn(async move {
|
|
task_barrier.wait().await;
|
|
playback_service
|
|
.switch(
|
|
room.id,
|
|
owner.id,
|
|
Some(media_id),
|
|
Some(static_child_id),
|
|
None,
|
|
)
|
|
.await
|
|
});
|
|
switch_barrier.wait().await;
|
|
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(250), &mut switch_task)
|
|
.await
|
|
.is_err(),
|
|
"playback switch must wait for an in-flight dynamic ancestor ban"
|
|
);
|
|
|
|
ban_tx
|
|
.commit()
|
|
.await
|
|
.checked("ban transaction should commit");
|
|
let switch_error = tokio::time::timeout(Duration::from_secs(5), switch_task)
|
|
.await
|
|
.checked("playback switch should finish after the ban commits")
|
|
.checked("playback switch task should join")
|
|
.failed("media below a banned dynamic ancestor must not become the playback source");
|
|
assert!(matches!(switch_error, Error::Authorization(_)));
|
|
|
|
let state = playback_repo
|
|
.get(&room.id)
|
|
.await
|
|
.checked("playback state should load")
|
|
.checked("playback state should exist");
|
|
assert!(state.playing_media_id.is_none());
|
|
assert!(state.playing_playlist_id.is_none());
|
|
assert!(!state.is_playing);
|
|
|
|
make_user_service(&pool)
|
|
.unban_user(&dynamic_creator.id)
|
|
.await
|
|
.checked("dynamic creator should be unbanned");
|
|
room_service
|
|
.playback_service()
|
|
.switch(room.id, owner.id, Some(media_id), None, None)
|
|
.await
|
|
.checked("playback should switch after the dynamic creator is unbanned");
|
|
|
|
user_repo
|
|
.ban(
|
|
&dynamic_creator.id,
|
|
Some(&owner.id),
|
|
Some("static child playback path test".to_string()),
|
|
)
|
|
.await
|
|
.checked("dynamic creator should be banned for the path check");
|
|
let error = room_service
|
|
.playback_service()
|
|
.switch(
|
|
room.id,
|
|
owner.id,
|
|
Some(media_id),
|
|
Some(static_child_id),
|
|
None,
|
|
)
|
|
.await
|
|
.failed("media below an inactive dynamic ancestor must not be playable");
|
|
assert!(matches!(error, Error::Authorization(_)));
|
|
|
|
make_user_service(&pool)
|
|
.unban_user(&dynamic_creator.id)
|
|
.await
|
|
.checked("dynamic creator should be unbanned after the path check");
|
|
room_service
|
|
.playback_service()
|
|
.switch(
|
|
room.id,
|
|
owner.id,
|
|
Some(media_id),
|
|
Some(static_child_id),
|
|
None,
|
|
)
|
|
.await
|
|
.checked("playback should switch after the dynamic ancestor is restored");
|
|
|
|
room_service
|
|
.ban_user_and_reset_owned_playback_with_outbox(
|
|
&dynamic_creator.id,
|
|
Some(&owner.id),
|
|
Some("post-switch dynamic ancestor test".to_string()),
|
|
None,
|
|
&[],
|
|
)
|
|
.await
|
|
.checked("banning a dynamic ancestor creator should reset existing playback");
|
|
let state = playback_repo
|
|
.get(&room.id)
|
|
.await
|
|
.checked("playback state should load")
|
|
.checked("playback state should exist");
|
|
assert!(state.playing_media_id.is_none());
|
|
assert!(state.playing_playlist_id.is_none());
|
|
assert!(!state.is_playing);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_switch_with_empty_target_clears_playback_state() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("switch_clear_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Switch Clear Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: None,
|
|
room_id: room.id,
|
|
creator_id: Some(owner.id),
|
|
name: "Clearable Video".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/clearable.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 media = media_repo
|
|
.create(&media)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
playback_service
|
|
.switch(room.id, owner.id, Some(media.id), None, None)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
playback_service
|
|
.seek(room.id, owner.id, 33.0)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let state = playback_service
|
|
.switch(room.id, owner.id, None, None, None)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
assert!(state.playing_media_id.is_none());
|
|
assert!(state.playing_playlist_id.is_none());
|
|
assert!(state.target.is_none());
|
|
assert!((state.position - 0.0).abs() < f64::EPSILON);
|
|
assert!((state.speed - 1.0).abs() < f64::EPSILON);
|
|
assert!(!state.is_playing);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_optimistic_lock_concurrent() {
|
|
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 owner = user_repo
|
|
.create(&make_user("olc_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room("OLC Room".to_string(), String::new(), owner.id, None, None)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
attach_test_media(&pool, room.id, owner.id).await;
|
|
|
|
// Spawn multiple concurrent seek operations
|
|
let mut handles = vec![];
|
|
for i in 0..5 {
|
|
let rs = room_service.clone();
|
|
let rid = room.id;
|
|
let uid = owner.id;
|
|
let position = f64::from(i) * 10.0;
|
|
|
|
let handle =
|
|
tokio::spawn(async move { rs.playback_service().seek(rid, uid, position).await });
|
|
handles.push(handle);
|
|
}
|
|
|
|
let results: Vec<_> = futures::future::join_all(handles).await;
|
|
|
|
// All operations should eventually succeed (optimistic locking with retries)
|
|
let mut success_count = 0;
|
|
for result in &results {
|
|
match result {
|
|
Ok(Ok(_)) => success_count += 1,
|
|
Ok(Err(e)) => {
|
|
// Some may fail with Internal error if retries exhausted, which is OK
|
|
assert!(
|
|
!matches!(e, Error::OptimisticLockConflict),
|
|
"OptimisticLockConflict should not leak to caller"
|
|
);
|
|
}
|
|
Err(e) => std::panic::panic_any(format!("seek task should complete: {e:?}")),
|
|
}
|
|
}
|
|
|
|
assert!(success_count > 0, "At least one seek should succeed");
|
|
|
|
// Final state should be valid
|
|
let playback_service = room_service.playback_service();
|
|
let state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
state.position >= 0.0,
|
|
"Final position should be non-negative"
|
|
);
|
|
}
|
|
|
|
/// Test rapid sequential seek operations (debounce/throttle behavior).
|
|
///
|
|
/// Scenario:
|
|
/// - 10 users concurrently seek to different positions
|
|
/// - All seeks should be processed (no debounce at service level)
|
|
/// - Final state should be one of the requested positions
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_rapid_sequential_seek_operations() {
|
|
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 owner = user_repo
|
|
.create(&make_user("rapid_seek_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Rapid Seek Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
attach_test_media(&pool, room.id, owner.id).await;
|
|
|
|
// Spawn 10 concurrent seek operations
|
|
let mut handles = vec![];
|
|
let barrier = Arc::new(tokio::sync::Barrier::new(10));
|
|
|
|
for i in 0..10 {
|
|
let rs = room_service.clone();
|
|
let rid = room.id;
|
|
let uid = owner.id;
|
|
let b = barrier.clone();
|
|
let position = f64::from(i).mul_add(30.0, 10.0); // 10, 40, 70, ...
|
|
|
|
let handle = tokio::spawn(async move {
|
|
b.wait().await;
|
|
rs.playback_service().seek(rid, uid, position).await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let results: Vec<_> = futures::future::join_all(handles).await;
|
|
|
|
// Count successes
|
|
let mut success_count = 0;
|
|
let mut positions = Vec::new();
|
|
|
|
for result in &results {
|
|
match result {
|
|
Ok(Ok(response)) => {
|
|
success_count += 1;
|
|
positions.push(response.state.position);
|
|
}
|
|
Ok(Err(_)) => {} // Some may fail due to conflicts
|
|
Err(e) => std::panic::panic_any(format!("seek task should complete: {e:?}")),
|
|
}
|
|
}
|
|
|
|
assert!(success_count > 0, "At least one seek should succeed");
|
|
|
|
// Final state should be valid
|
|
let playback_service = room_service.playback_service();
|
|
let state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
state.position >= 0.0,
|
|
"Final position should be non-negative"
|
|
);
|
|
assert!(
|
|
state.position <= 300.0,
|
|
"Final position should be <= 300 (max seek)"
|
|
);
|
|
}
|
|
|
|
/// Test `play_next` behavior when playlist is modified concurrently.
|
|
///
|
|
/// Scenario:
|
|
/// - Add multiple media items to playlist
|
|
/// - Start playing first item
|
|
/// - Concurrently call `play_next` and delete next media
|
|
/// - Verify system handles deletion gracefully
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_play_next_concurrent_playlist_modification() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
let room_service = Arc::new(make_room_service(pool.clone()));
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("playnext_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Play Next Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playlist = create_top_level_playlist(&pool, &room.id).await;
|
|
|
|
// Add 5 media items
|
|
let mut media_ids = Vec::new();
|
|
for i in 0..5 {
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: Some(playlist.id),
|
|
room_id: room.id,
|
|
creator_id: Some(owner.id),
|
|
name: format!("Video {i}"),
|
|
description: String::new(),
|
|
position: f64::from(i),
|
|
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 media = media_repo
|
|
.create(&media)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
media_ids.push(media.id);
|
|
}
|
|
|
|
let playback_service = room_service.playback_service();
|
|
playback_service
|
|
.switch(room.id, owner.id, Some(media_ids[0]), None, None)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Verify we're playing first media
|
|
let state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(state.playing_media_id, Some(media_ids[0]));
|
|
|
|
// Set up RoomSettings with auto_play enabled
|
|
let settings = RoomSettings::default();
|
|
|
|
// Call play_next - should advance to second media
|
|
let result = playback_service
|
|
.play_next(&room.id, &settings)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
assert!(result.is_some(), "play_next should return new state");
|
|
let new_state = result.checked("test operation should succeed");
|
|
assert_eq!(new_state.playing_media_id, Some(media_ids[1]));
|
|
}
|
|
|
|
/// Test `play_next` behavior at the end of playlist.
|
|
///
|
|
/// Scenario:
|
|
/// - Playlist has 3 items
|
|
/// - Play to last item
|
|
/// - Call `play_next`
|
|
/// - Should return None (no more items) or loop depending on settings
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_play_next_at_end_of_playlist() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
let room_service = Arc::new(make_room_service(pool.clone()));
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("playlist_end_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Playlist End Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playlist = create_top_level_playlist(&pool, &room.id).await;
|
|
|
|
// Add 3 media items
|
|
let mut media_ids = Vec::new();
|
|
for i in 0..3 {
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: Some(playlist.id),
|
|
room_id: room.id,
|
|
creator_id: Some(owner.id),
|
|
name: format!("End Video {i}"),
|
|
description: String::new(),
|
|
position: f64::from(i),
|
|
source_provider: SourceProvider::DirectUrl,
|
|
source_config: synctv_core_testing::direct_url_media_source_config(format!(
|
|
"https://example.com/end{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 media = media_repo
|
|
.create(&media)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
media_ids.push(media.id);
|
|
}
|
|
|
|
let playback_service = room_service.playback_service();
|
|
playback_service
|
|
.switch(room.id, owner.id, Some(media_ids[2]), None, None)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Sequential mode (no loop) - play_next should return None
|
|
let settings = RoomSettings {
|
|
auto_play: room_settings::AutoPlay::new(synctv_core::models::room::AutoPlaySettings {
|
|
enabled: false,
|
|
mode: synctv_core::models::PlayMode::Sequential,
|
|
delay: 0,
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let result = playback_service
|
|
.play_next(&room.id, &settings)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// At end of playlist with no loop, should return None
|
|
// (or the implementation may return the state unchanged)
|
|
match result {
|
|
None => {} // Expected: end of playlist
|
|
Some(state) => {
|
|
// If it returns a state, it should be the same (no change)
|
|
assert_eq!(state.playing_media_id, Some(media_ids[2]));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Test `play_next` with loop enabled returns to first item.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_play_next_with_loop_enabled() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
let room_service = Arc::new(make_room_service(pool.clone()));
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("loop_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room("Loop Room".to_string(), String::new(), owner.id, None, None)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playlist = create_top_level_playlist(&pool, &room.id).await;
|
|
|
|
// Add 3 media items
|
|
let mut media_ids = Vec::new();
|
|
for i in 0..3 {
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: Some(playlist.id),
|
|
room_id: room.id,
|
|
creator_id: Some(owner.id),
|
|
name: format!("Loop Video {i}"),
|
|
description: String::new(),
|
|
position: f64::from(i),
|
|
source_provider: SourceProvider::DirectUrl,
|
|
source_config: synctv_core_testing::direct_url_media_source_config(format!(
|
|
"https://example.com/loop{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 media = media_repo
|
|
.create(&media)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
media_ids.push(media.id);
|
|
}
|
|
|
|
let playback_service = room_service.playback_service();
|
|
playback_service
|
|
.switch(room.id, owner.id, Some(media_ids[2]), None, None)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Loop (RepeatAll) mode enabled via auto_play
|
|
let settings = RoomSettings {
|
|
auto_play: room_settings::AutoPlay {
|
|
value: AutoPlaySettings {
|
|
enabled: true,
|
|
mode: PlayMode::RepeatAll,
|
|
delay: 0,
|
|
},
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
let result = playback_service
|
|
.play_next(&room.id, &settings)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// With loop, should return to first item
|
|
assert!(result.is_some(), "With loop, play_next should return state");
|
|
let state = result.checked("test operation should succeed");
|
|
assert_eq!(state.playing_media_id, Some(media_ids[0]));
|
|
}
|
|
|
|
/// Test behavior when playlist is empty.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_empty_playlist_handling() {
|
|
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 owner = user_repo
|
|
.create(&make_user("empty_playlist_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Empty Playlist Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
let settings = RoomSettings::default();
|
|
|
|
// Play next on empty playlist should return None
|
|
let result = playback_service
|
|
.play_next(&room.id, &settings)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
assert!(
|
|
result.is_none(),
|
|
"play_next on empty playlist should return None"
|
|
);
|
|
|
|
// Get state should work
|
|
let state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
state.playing_media_id.is_none(),
|
|
"No media should be playing"
|
|
);
|
|
}
|
|
|
|
/// Test concurrent speed changes.
|
|
///
|
|
/// Scenario:
|
|
/// - Multiple concurrent requests to change speed
|
|
/// - All should eventually succeed or fail gracefully
|
|
/// - Final state should be consistent
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_concurrent_speed_changes() {
|
|
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 owner = user_repo
|
|
.create(&make_user("speed_concurrent_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Speed Concurrent Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Spawn 10 concurrent speed change operations
|
|
let mut handles = vec![];
|
|
let barrier = Arc::new(tokio::sync::Barrier::new(10));
|
|
|
|
let valid_speeds = [0.5, 1.0, 1.5, 2.0, 0.75, 1.25, 1.75, 2.5, 3.0, 4.0];
|
|
|
|
for speed in valid_speeds {
|
|
let rs = room_service.clone();
|
|
let rid = room.id;
|
|
let uid = owner.id;
|
|
let b = barrier.clone();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
b.wait().await;
|
|
rs.playback_service().change_speed(rid, uid, speed).await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let results: Vec<_> = futures::future::join_all(handles).await;
|
|
|
|
let mut success_count = 0;
|
|
for result in &results {
|
|
match result {
|
|
Ok(Ok(_)) => success_count += 1,
|
|
Ok(Err(_)) => {} // Some may fail due to conflicts
|
|
Err(e) => std::panic::panic_any(format!("playback task should complete: {e:?}")),
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
success_count > 0,
|
|
"At least one speed change should succeed"
|
|
);
|
|
|
|
// Final state should have a valid speed
|
|
let playback_service = room_service.playback_service();
|
|
let state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(state.speed > 0.0, "Speed should be positive");
|
|
assert!(state.speed <= 4.0, "Speed should be <= max");
|
|
}
|
|
|
|
/// Test that state remains consistent after multiple mixed operations.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_state_consistency_after_mixed_operations() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
let room_service = Arc::new(make_room_service(pool.clone()));
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("mixed_ops_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Mixed Ops Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playlist = create_top_level_playlist(&pool, &room.id).await;
|
|
|
|
// Add media
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: Some(playlist.id),
|
|
room_id: room.id,
|
|
creator_id: Some(owner.id),
|
|
name: "Mixed Test Video".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/mixed.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 media = media_repo
|
|
.create(&media)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
|
|
// Switch to media
|
|
playback_service
|
|
.switch(room.id, owner.id, Some(media.id), None, None)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Seek
|
|
let seek_response = playback_service
|
|
.seek(room.id, owner.id, 50.0)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(seek_response.seek_applied, "Seek should be applied");
|
|
assert!(
|
|
(seek_response.state.position - 50.0).abs() < f64::EPSILON,
|
|
"Seek should set the exact requested position"
|
|
);
|
|
|
|
// Change speed while playing: the effective position may advance slightly,
|
|
// but it must never move backward from the seek target.
|
|
let speed_state = playback_service
|
|
.change_speed(room.id, owner.id, 1.5)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
speed_state.position >= 50.0,
|
|
"Position must not move backward after changing speed"
|
|
);
|
|
|
|
// Pause snapshots the computed playback position.
|
|
let paused_state = playback_service
|
|
.set_playing(room.id, owner.id, false)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
paused_state.position >= speed_state.position,
|
|
"Pause should preserve or advance the effective position"
|
|
);
|
|
assert!(!paused_state.is_playing, "Pause should stop playback");
|
|
|
|
// Resume should preserve the paused position and flip playback back on.
|
|
let resumed_state = playback_service
|
|
.set_playing(room.id, owner.id, true)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
(resumed_state.position - paused_state.position).abs() < 0.1,
|
|
"Resume should preserve the paused position"
|
|
);
|
|
|
|
// Verify final state is consistent.
|
|
let state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
assert_eq!(state.playing_media_id, Some(media.id));
|
|
assert!(
|
|
state.position >= paused_state.position,
|
|
"Final position should not move backward"
|
|
);
|
|
assert!(
|
|
(state.speed - 1.5).abs() < f64::EPSILON,
|
|
"Speed should be 1.5"
|
|
);
|
|
assert!(state.is_playing, "Should be playing");
|
|
}
|
|
|
|
use synctv_core::models::{room_settings, RoomSettings};
|
|
|
|
/// Successful seek responses set `seek_applied=true`.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_seek_success_returns_applied_true() {
|
|
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("seek_response_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Seek Response Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
attach_test_media(&pool, room.id, owner.id).await;
|
|
|
|
// A simple seek should succeed and report seek_applied=true
|
|
let response = playback_service
|
|
.seek(room.id, owner.id, 42.5)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
assert!(
|
|
response.seek_applied,
|
|
"Successful seek should have seek_applied=true"
|
|
);
|
|
assert!(
|
|
(response.state.position - 42.5).abs() < f64::EPSILON,
|
|
"Position should be 42.5, got: {}",
|
|
response.state.position
|
|
);
|
|
}
|
|
|
|
/// Test that seek returns `seek_applied=false` with degraded response on retry exhaustion.
|
|
///
|
|
/// When optimistic lock retries are exhausted during rapid concurrent seeks,
|
|
/// the method should return the latest state but with `seek_applied=false`
|
|
/// so the client knows the requested position was not applied.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_seek_retry_exhaustion_returns_applied_false() {
|
|
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 owner = user_repo
|
|
.create(&make_user("seek_retry_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Seek Retry Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
attach_test_media(&pool, room.id, owner.id).await;
|
|
|
|
// Spawn many concurrent seeks to trigger retry exhaustion
|
|
let mut handles = vec![];
|
|
let barrier = Arc::new(tokio::sync::Barrier::new(50));
|
|
|
|
for i in 0..50 {
|
|
let rs = room_service.clone();
|
|
let rid = room.id;
|
|
let uid = owner.id;
|
|
let b = barrier.clone();
|
|
let position = f64::from(i).mul_add(10.0, 1.0); // Different positions
|
|
|
|
let handle = tokio::spawn(async move {
|
|
b.wait().await;
|
|
rs.playback_service().seek(rid, uid, position).await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let results: Vec<_> = futures::future::join_all(handles).await;
|
|
|
|
// At least some should succeed, some may have retry exhaustion
|
|
let mut success_count = 0;
|
|
|
|
for result in &results {
|
|
match result {
|
|
Ok(Ok(response)) => {
|
|
if response.seek_applied {
|
|
success_count += 1;
|
|
} else {
|
|
// Degraded response should still have valid state
|
|
assert!(
|
|
response.state.position >= 0.0,
|
|
"Degraded response should have valid position"
|
|
);
|
|
}
|
|
}
|
|
Ok(Err(_)) => {} // Other errors are OK
|
|
Err(e) => std::panic::panic_any(format!("playback task should complete: {e:?}")),
|
|
}
|
|
}
|
|
|
|
assert!(success_count > 0, "At least one seek should succeed");
|
|
// Note: degraded_count may be 0 if all succeed within retry budget
|
|
|
|
// Final state should be valid
|
|
let final_state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
final_state.position >= 0.0,
|
|
"Final position should be non-negative"
|
|
);
|
|
}
|
|
|
|
/// Test that `SeekResponse` contains the state even when seek fails.
|
|
///
|
|
/// Clients should always get a valid state in the response,
|
|
/// regardless of whether the seek was applied.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_seek_response_always_contains_valid_state() {
|
|
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("seek_state_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Seek State Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
attach_test_media(&pool, room.id, owner.id).await;
|
|
|
|
// First set a known position
|
|
playback_service
|
|
.seek(room.id, owner.id, 100.0)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Now seek to a different position
|
|
let response = playback_service
|
|
.seek(room.id, owner.id, 200.0)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Response should have valid state (either at 200 if applied, or playback position)
|
|
assert!(
|
|
response.state.position >= 0.0,
|
|
"State should have valid position"
|
|
);
|
|
assert!(response.state.speed > 0.0, "State should have valid speed");
|
|
}
|
|
|
|
/// Test `SeekResponse` message field is informative when seek fails.
|
|
///
|
|
/// When seek fails due to contention, the message should help debugging.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_seek_degraded_response_has_informative_message() {
|
|
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 owner = user_repo
|
|
.create(&make_user("seek_msg_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Seek Message Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
attach_test_media(&pool, room.id, owner.id).await;
|
|
|
|
// Spawn many concurrent seeks
|
|
let mut handles = vec![];
|
|
let barrier = Arc::new(tokio::sync::Barrier::new(100));
|
|
|
|
for i in 0..100 {
|
|
let rs = room_service.clone();
|
|
let rid = room.id;
|
|
let uid = owner.id;
|
|
let b = barrier.clone();
|
|
let position = f64::from(i) * 5.0;
|
|
|
|
let handle = tokio::spawn(async move {
|
|
b.wait().await;
|
|
rs.playback_service().seek(rid, uid, position).await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let results: Vec<_> = futures::future::join_all(handles).await;
|
|
|
|
// Check for degraded responses with message
|
|
for result in &results {
|
|
match result {
|
|
Ok(Ok(response)) => {
|
|
if !response.seek_applied {
|
|
// Degraded response should have an informative message
|
|
if let Some(msg) = &response.message {
|
|
assert!(
|
|
msg.contains("retry")
|
|
|| msg.contains("contention")
|
|
|| msg.contains("failed")
|
|
|| msg.contains("concurrent"),
|
|
"Degraded response message should explain failure: {msg}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(Err(_)) => {}
|
|
Err(e) => std::panic::panic_any(format!("playback task should complete: {e:?}")),
|
|
}
|
|
}
|
|
}
|