|
|
|
|
//! Room concurrent member management tests
|
|
|
|
|
//!
|
|
|
|
|
//! Tests concurrent member operations including concurrent joins, role updates,
|
|
|
|
|
//! permission changes, and optimistic lock conflict retry.
|
|
|
|
|
//!
|
|
|
|
|
//!
|
|
|
|
|
//! # Test Coverage
|
|
|
|
|
//!
|
|
|
|
|
//! - Concurrent join with `max_members` limit
|
|
|
|
|
//! - Concurrent role updates with role hierarchy enforcement
|
|
|
|
|
//! - Concurrent permission changes with retry
|
|
|
|
|
//! - Optimistic lock conflict handling and retry
|
|
|
|
|
//!
|
|
|
|
|
//! # Requirements
|
|
|
|
|
//!
|
|
|
|
|
//! - Docker for testcontainers (`PostgreSQL`)
|
|
|
|
|
|
|
|
|
|
use chrono::Utc;
|
|
|
|
|
use sqlx::PgPool;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use synctv_core::{
|
|
|
|
|
models::{
|
|
|
|
|
room_settings::MaxMembers, AddMemberOptions, Room, RoomAdminPermissionBits, RoomId,
|
|
|
|
|
RoomMember, RoomRole, RoomSettings, RoomStatus, User, UserId, UserRole, UserStatus,
|
|
|
|
|
},
|
|
|
|
|
repository::{RoomMemberRepository, RoomRepository, RoomSettingsRepository, UserRepository},
|
|
|
|
|
service::{MemberService, NotificationService, PermissionService},
|
|
|
|
|
Error,
|
|
|
|
|
};
|
|
|
|
|
use synctv_core_testing::{
|
|
|
|
|
create_test_database_with_options_and_label, some, TestDatabase, TestResultExt,
|
|
|
|
|
};
|
|
|
|
|
use tokio::sync::Barrier;
|
|
|
|
|
// Test Infrastructure
|
|
|
|
|
|
|
|
|
|
async fn create_test_pool() -> TestDatabase {
|
|
|
|
|
create_test_database_with_options_and_label(
|
|
|
|
|
"synctv_test",
|
|
|
|
|
"room-concurrent",
|
|
|
|
|
30,
|
|
|
|
|
std::time::Duration::from_secs(30),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create a test user in the database
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create a test room
|
|
|
|
|
fn make_room(name: &str, description: &str, owner: &UserId) -> Room {
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
Room {
|
|
|
|
|
id: RoomId::new(),
|
|
|
|
|
name: name.to_string(),
|
|
|
|
|
description: description.to_string(),
|
|
|
|
|
cover_file_reference_id: None,
|
|
|
|
|
category: None,
|
|
|
|
|
labels: Vec::new(),
|
|
|
|
|
created_by: *owner,
|
|
|
|
|
status: RoomStatus::Active,
|
|
|
|
|
is_banned: false,
|
|
|
|
|
is_public: true,
|
|
|
|
|
closed_at: None,
|
|
|
|
|
created_at: now,
|
|
|
|
|
updated_at: now,
|
|
|
|
|
deleted_at: None,
|
|
|
|
|
version: 0,
|
|
|
|
|
last_activity_at: now,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Setup test infrastructure with users and a room
|
|
|
|
|
async fn setup_test_room(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
room_name: &str,
|
|
|
|
|
max_members: u64,
|
|
|
|
|
) -> (User, Room, RoomSettings) {
|
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
|
let room_settings_repo = RoomSettingsRepository::new(pool.clone());
|
|
|
|
|
|
|
|
|
|
let owner = user_repo
|
|
|
|
|
.create(&make_user("room_owner"))
|
|
|
|
|
.await
|
|
|
|
|
.checked("owner should be created");
|
|
|
|
|
|
|
|
|
|
let room = room_repo
|
|
|
|
|
.create(&make_room(room_name, "Test room", &owner.id))
|
|
|
|
|
.await
|
|
|
|
|
.checked("room should be created");
|
|
|
|
|
|
|
|
|
|
let settings = RoomSettings {
|
|
|
|
|
max_members: MaxMembers(max_members),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
room_settings_repo
|
|
|
|
|
.set_settings(&room.id, &settings)
|
|
|
|
|
.await
|
|
|
|
|
.checked("room settings should be created");
|
|
|
|
|
|
|
|
|
|
// Add owner as member (Creator)
|
|
|
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
|
|
|
let owner_member = RoomMember::new(room.id, owner.id, RoomRole::Creator);
|
|
|
|
|
member_repo
|
|
|
|
|
.add(&owner_member)
|
|
|
|
|
.await
|
|
|
|
|
.checked("owner member should be added");
|
|
|
|
|
|
|
|
|
|
(owner, room, settings)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get creator user ID from room
|
|
|
|
|
async fn get_creator_user_id(pool: &PgPool, room_id: &RoomId) -> UserId {
|
|
|
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
|
|
|
let members = member_repo
|
|
|
|
|
.list_by_room_all(room_id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("members should be listed");
|
|
|
|
|
some(
|
|
|
|
|
members.iter().find(|m| m.role == RoomRole::Creator),
|
|
|
|
|
"creator should exist",
|
|
|
|
|
)
|
|
|
|
|
.user_id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test concurrent join operations respect `max_members` limit.
|
|
|
|
|
///
|
|
|
|
|
/// Scenario:
|
|
|
|
|
/// 1. Create a room with `max_members` = 10
|
|
|
|
|
/// 2. Spawn 30 concurrent join requests
|
|
|
|
|
/// 3. Verify exactly 9 succeed (room capacity 10 - owner = 9)
|
|
|
|
|
/// 4. Verify final member count is 10
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_concurrent_join_respects_max_members() {
|
|
|
|
|
let infra = create_test_database_with_options_and_label(
|
|
|
|
|
"synctv_test",
|
|
|
|
|
"concurrent-join-max-members",
|
|
|
|
|
30,
|
|
|
|
|
std::time::Duration::from_secs(30),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
let pool = &infra.pool;
|
|
|
|
|
|
|
|
|
|
let (_owner, room, _settings) = setup_test_room(pool, "Concurrent Join Room", 10).await;
|
|
|
|
|
|
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
|
let mut users = Vec::with_capacity(30);
|
|
|
|
|
for i in 0..30 {
|
|
|
|
|
let user = user_repo
|
|
|
|
|
.create(&make_user(&format!("joiner_{i}")))
|
|
|
|
|
.await
|
|
|
|
|
.checked("user should be created");
|
|
|
|
|
users.push(user);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Setup services
|
|
|
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
|
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
|
let room_settings_repo = RoomSettingsRepository::new(pool.clone());
|
|
|
|
|
let permission_service =
|
|
|
|
|
PermissionService::new(member_repo.clone(), room_repo.clone(), None, 1000, 300)
|
|
|
|
|
.checked("permission service should build");
|
|
|
|
|
let member_service = MemberService::new_with_runtime(
|
|
|
|
|
member_repo.clone(),
|
|
|
|
|
room_repo.clone(),
|
|
|
|
|
Some(room_settings_repo),
|
|
|
|
|
permission_service.clone(),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
NotificationService::default(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Use barrier to synchronize all joins
|
|
|
|
|
let barrier = Arc::new(Barrier::new(30));
|
|
|
|
|
let room_id = room.id;
|
|
|
|
|
|
|
|
|
|
// Spawn 30 concurrent join tasks
|
|
|
|
|
let mut handles = Vec::with_capacity(30);
|
|
|
|
|
for user in users {
|
|
|
|
|
let barrier_clone = barrier.clone();
|
|
|
|
|
let member_service_clone = member_service.clone();
|
|
|
|
|
let room_id_clone = room_id;
|
|
|
|
|
|
|
|
|
|
let handle = tokio::spawn(async move {
|
|
|
|
|
barrier_clone.wait().await;
|
|
|
|
|
|
refactor: remove low-value thin wrappers (#426)
## Summary
- remove unused compatibility APIs and low-value forwarding wrappers
across the workspace
- inline service, repository, provider, cluster, proxy, and protocol
paths while preserving existing behavior
- remove stale dependencies, metrics, tests, and SQLx metadata generated
for deleted queries
## Validation
- `cargo check --workspace --all-targets`
- `cargo fmt --all -- --check`
- `git diff --check`
- relevant test suites: 1,408 passed, 184 ignored
- broader test run: 2,286 passed
1 month ago
|
|
|
let options = AddMemberOptions::default().with_max_members(0);
|
|
|
|
|
member_service_clone
|
|
|
|
|
.add_member_with_options(room_id_clone, user.id, RoomRole::Member, options)
|
|
|
|
|
.await
|
|
|
|
|
});
|
|
|
|
|
handles.push(handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect results
|
|
|
|
|
let mut success_count = 0;
|
|
|
|
|
let mut limit_reached_count = 0;
|
|
|
|
|
|
|
|
|
|
for handle in handles {
|
|
|
|
|
match handle.await.checked("task should complete") {
|
|
|
|
|
Ok(_) => success_count += 1,
|
|
|
|
|
Err(Error::InvalidInput(msg)) if msg.contains("Room is full") => {
|
|
|
|
|
limit_reached_count += 1;
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
std::panic::panic_any(format!("unexpected join error: {e:?}"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify: With max_members=10 and owner already a member, only 9 more can join
|
|
|
|
|
assert_eq!(success_count, 9, "Expected exactly 9 joins to succeed");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
limit_reached_count, 21,
|
|
|
|
|
"Expected 21 joins to be rejected (room full)"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Verify final member count
|
|
|
|
|
let member_count: i64 = sqlx::query_scalar!(
|
|
|
|
|
r#"SELECT COUNT(*) AS "count!" FROM room_members WHERE room_id = $1"#,
|
|
|
|
|
room.id.as_i64()
|
|
|
|
|
)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.checked("members should be counted");
|
|
|
|
|
|
|
|
|
|
assert_eq!(member_count, 10, "Final member count should be 10");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test concurrent role updates with role hierarchy enforcement.
|
|
|
|
|
///
|
|
|
|
|
/// Scenario:
|
|
|
|
|
/// 1. Create a room with creator and multiple members
|
|
|
|
|
/// 2. Concurrently update roles from Member to Admin
|
|
|
|
|
/// 3. Verify all updates succeed
|
|
|
|
|
/// 4. Verify role hierarchy is maintained
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_concurrent_role_update_member_to_admin() {
|
|
|
|
|
let infra = create_test_pool().await;
|
|
|
|
|
let pool = &infra.pool;
|
|
|
|
|
|
|
|
|
|
let (_owner, room, _settings) = setup_test_room(pool, "Role Update Room", 100).await;
|
|
|
|
|
|
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
|
|
|
let mut member_users = Vec::with_capacity(10);
|
|
|
|
|
|
|
|
|
|
for i in 0..10 {
|
|
|
|
|
let user = user_repo
|
|
|
|
|
.create(&make_user(&format!("member_{i}")))
|
|
|
|
|
.await
|
|
|
|
|
.checked("user should be created");
|
|
|
|
|
let member = RoomMember::new(room.id, user.id, RoomRole::Member);
|
|
|
|
|
member_repo
|
|
|
|
|
.add(&member)
|
|
|
|
|
.await
|
|
|
|
|
.checked("member should be added");
|
|
|
|
|
member_users.push(user);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Setup services
|
|
|
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
|
let room_settings_repo = RoomSettingsRepository::new(pool.clone());
|
|
|
|
|
let permission_service =
|
|
|
|
|
PermissionService::new(member_repo.clone(), room_repo.clone(), None, 1000, 300)
|
|
|
|
|
.checked("permission service should build");
|
|
|
|
|
let member_service = MemberService::new_with_runtime(
|
|
|
|
|
member_repo.clone(),
|
|
|
|
|
room_repo.clone(),
|
|
|
|
|
Some(room_settings_repo),
|
|
|
|
|
permission_service.clone(),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
NotificationService::default(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Get owner user ID
|
|
|
|
|
let operator_id = get_creator_user_id(pool, &room.id).await;
|
|
|
|
|
|
|
|
|
|
// Use barrier for concurrent updates
|
|
|
|
|
let barrier = Arc::new(Barrier::new(10));
|
|
|
|
|
let room_id = room.id;
|
|
|
|
|
|
|
|
|
|
let mut handles = Vec::with_capacity(10);
|
|
|
|
|
for user in member_users {
|
|
|
|
|
let barrier_clone = barrier.clone();
|
|
|
|
|
let member_service_clone = member_service.clone();
|
|
|
|
|
let room_id_clone = room_id;
|
|
|
|
|
|
|
|
|
|
let handle = tokio::spawn(async move {
|
|
|
|
|
barrier_clone.wait().await;
|
|
|
|
|
member_service_clone
|
|
|
|
|
.set_member_role(room_id_clone, operator_id, user.id, RoomRole::Admin)
|
|
|
|
|
.await
|
|
|
|
|
});
|
|
|
|
|
handles.push(handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect results
|
|
|
|
|
let mut success_count = 0;
|
|
|
|
|
for handle in handles {
|
|
|
|
|
match handle.await.checked("task should complete") {
|
|
|
|
|
Ok(_) => success_count += 1,
|
|
|
|
|
Err(e) => tracing::warn!("Role update failed: {:?}", e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// All role updates should succeed
|
|
|
|
|
assert_eq!(success_count, 10, "All role updates should succeed");
|
|
|
|
|
|
|
|
|
|
// Verify all members now have Admin role
|
|
|
|
|
let operator_id = get_creator_user_id(pool, &room.id).await;
|
|
|
|
|
for member in member_repo
|
|
|
|
|
.list_by_room_all(&room.id)
|
|
|
|
|
.await
|
|
|
|
|
.checked("members should be listed")
|
|
|
|
|
{
|
|
|
|
|
if member.user_id != operator_id {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
member.role,
|
|
|
|
|
RoomRole::Admin,
|
|
|
|
|
"Member should be promoted to Admin"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test concurrent role update rejection (lower role cannot update higher role).
|
|
|
|
|
///
|
|
|
|
|
/// Scenario:
|
|
|
|
|
/// 1. Create room with creator and 2 admins
|
|
|
|
|
/// 2. Admin1 tries to demote Admin2 to Member
|
|
|
|
|
/// 3. Admin2 tries to demote Admin1 to Member
|
|
|
|
|
/// 4. Both should fail due to equal role hierarchy
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_concurrent_role_update_equal_role_rejected() {
|
|
|
|
|
let infra = create_test_pool().await;
|
|
|
|
|
let pool = &infra.pool;
|
|
|
|
|
|
|
|
|
|
let (_owner, room, _settings) = setup_test_room(pool, "Equal Role Room", 100).await;
|
|
|
|
|
|
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
|
|
|
|
|
|
|
|
let admin1 = user_repo
|
|
|
|
|
.create(&make_user("admin1"))
|
|
|
|
|
.await
|
|
|
|
|
.checked("admin1 should be created");
|
|
|
|
|
let admin1_member = RoomMember::new(room.id, admin1.id, RoomRole::Admin);
|
|
|
|
|
member_repo
|
|
|
|
|
.add(&admin1_member)
|
|
|
|
|
.await
|
|
|
|
|
.checked("admin1 member should be added");
|
|
|
|
|
|
|
|
|
|
let admin2 = user_repo
|
|
|
|
|
.create(&make_user("admin2"))
|
|
|
|
|
.await
|
|
|
|
|
.checked("admin2 should be created");
|
|
|
|
|
let admin2_member = RoomMember::new(room.id, admin2.id, RoomRole::Admin);
|
|
|
|
|
member_repo
|
|
|
|
|
.add(&admin2_member)
|
|
|
|
|
.await
|
|
|
|
|
.checked("admin2 member should be added");
|
|
|
|
|
|
|
|
|
|
// Setup services
|
|
|
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
|
let room_settings_repo = RoomSettingsRepository::new(pool.clone());
|
|
|
|
|
let permission_service =
|
|
|
|
|
PermissionService::new(member_repo.clone(), room_repo.clone(), None, 1000, 300)
|
|
|
|
|
.checked("permission service should build");
|
|
|
|
|
let member_service = MemberService::new_with_runtime(
|
|
|
|
|
member_repo.clone(),
|
|
|
|
|
room_repo.clone(),
|
|
|
|
|
Some(room_settings_repo),
|
|
|
|
|
permission_service.clone(),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
NotificationService::default(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Admin1 tries to demote Admin2
|
|
|
|
|
let result1 = member_service
|
|
|
|
|
.set_member_role(room.id, admin1.id, admin2.id, RoomRole::Member)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// Admin2 tries to demote Admin1
|
|
|
|
|
let result2 = member_service
|
|
|
|
|
.set_member_role(room.id, admin2.id, admin1.id, RoomRole::Member)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// Both should fail due to equal role hierarchy
|
|
|
|
|
assert!(result1.is_err(), "Admin1 cannot demote Admin2 (equal role)");
|
|
|
|
|
assert!(result2.is_err(), "Admin2 cannot demote Admin1 (equal role)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test concurrent permission changes with optimistic lock retry.
|
|
|
|
|
///
|
|
|
|
|
/// Scenario:
|
|
|
|
|
/// 1. Create room with creator and multiple members
|
|
|
|
|
/// 2. Creator concurrently grants different permissions to different members
|
|
|
|
|
/// 3. All operations should succeed through optimistic lock retry
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_concurrent_permission_grant() {
|
|
|
|
|
let infra = create_test_pool().await;
|
|
|
|
|
let pool = &infra.pool;
|
|
|
|
|
|
|
|
|
|
let (_owner, room, _settings) = setup_test_room(pool, "Permission Grant Room", 100).await;
|
|
|
|
|
|
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
|
|
|
let mut member_users = Vec::with_capacity(5);
|
|
|
|
|
|
|
|
|
|
for i in 0..5 {
|
|
|
|
|
let user = user_repo
|
|
|
|
|
.create(&make_user(&format!("perm_member_{i}")))
|
|
|
|
|
.await
|
|
|
|
|
.checked("user should be created");
|
|
|
|
|
let member = RoomMember::new(room.id, user.id, RoomRole::Member);
|
|
|
|
|
member_repo
|
|
|
|
|
.add(&member)
|
|
|
|
|
.await
|
|
|
|
|
.checked("member should be added");
|
|
|
|
|
member_users.push(user);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Setup services
|
|
|
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
|
let _room_settings_repo = RoomSettingsRepository::new(pool.clone());
|
|
|
|
|
let permission_service =
|
|
|
|
|
PermissionService::new(member_repo.clone(), room_repo.clone(), None, 1000, 300)
|
|
|
|
|
.checked("permission service should build");
|
|
|
|
|
let member_service = Arc::new(MemberService::new(
|
|
|
|
|
member_repo.clone(),
|
|
|
|
|
room_repo.clone(),
|
|
|
|
|
permission_service.clone(),
|
|
|
|
|
NotificationService::default(),
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
// Get owner user ID
|
|
|
|
|
let operator_id = get_creator_user_id(pool, &room.id).await;
|
|
|
|
|
|
|
|
|
|
// Use barrier for concurrent operations
|
|
|
|
|
let barrier = Arc::new(Barrier::new(5));
|
|
|
|
|
let room_id = room.id;
|
|
|
|
|
|
|
|
|
|
let mut handles = Vec::with_capacity(5);
|
|
|
|
|
for (i, user) in member_users.into_iter().enumerate() {
|
|
|
|
|
let barrier_clone = barrier.clone();
|
|
|
|
|
let member_service_clone = member_service.clone();
|
|
|
|
|
let room_id_clone = room_id;
|
|
|
|
|
|
|
|
|
|
// Grant different permissions to each member
|
|
|
|
|
let permission = match i {
|
|
|
|
|
0 | 1 => RoomAdminPermissionBits::USE_VOICE_CHAT,
|
|
|
|
|
2 => RoomAdminPermissionBits::VIEW_CHAT_HISTORY,
|
|
|
|
|
3 => RoomAdminPermissionBits::SEND_CHAT_MESSAGES,
|
|
|
|
|
_ => RoomAdminPermissionBits::MANAGE_OWN_MEDIA,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let handle = tokio::spawn(async move {
|
|
|
|
|
barrier_clone.wait().await;
|
|
|
|
|
member_service_clone
|
|
|
|
|
.grant_permission(room_id_clone, operator_id, user.id, permission)
|
|
|
|
|
.await
|
|
|
|
|
});
|
|
|
|
|
handles.push(handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect results
|
|
|
|
|
let mut success_count = 0;
|
|
|
|
|
for handle in handles {
|
|
|
|
|
match handle.await.checked("task should complete") {
|
|
|
|
|
Ok(_) => success_count += 1,
|
|
|
|
|
Err(e) => tracing::warn!("Permission grant failed: {:?}", e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// All permission grants should succeed
|
|
|
|
|
assert!(
|
|
|
|
|
success_count >= 4,
|
|
|
|
|
"At least 4 permission grants should succeed"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test optimistic lock conflict handling and retry for member updates.
|
|
|
|
|
///
|
|
|
|
|
/// Scenario:
|
|
|
|
|
/// 1. Create room with creator and member
|
|
|
|
|
/// 2. Multiple tasks concurrently update member's permissions
|
|
|
|
|
/// 3. Database version conflict occurs
|
|
|
|
|
/// 4. Verify retry mechanism handles conflicts gracefully
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_optimistic_lock_conflict_retry_on_permission_update() {
|
|
|
|
|
let infra = create_test_pool().await;
|
|
|
|
|
let pool = &infra.pool;
|
|
|
|
|
|
|
|
|
|
let (_owner, room, _settings) = setup_test_room(pool, "Optimistic Lock Room", 100).await;
|
|
|
|
|
|
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
|
|
|
|
|
|
|
|
let target_user = user_repo
|
|
|
|
|
.create(&make_user("target_member"))
|
|
|
|
|
.await
|
|
|
|
|
.checked("target user should be created");
|
|
|
|
|
let target_member = RoomMember::new(room.id, target_user.id, RoomRole::Member);
|
|
|
|
|
member_repo
|
|
|
|
|
.add(&target_member)
|
|
|
|
|
.await
|
|
|
|
|
.checked("member should be added");
|
|
|
|
|
|
|
|
|
|
// Setup services
|
|
|
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
|
let permission_service =
|
|
|
|
|
PermissionService::new(member_repo.clone(), room_repo.clone(), None, 1000, 300)
|
|
|
|
|
.checked("permission service should build");
|
|
|
|
|
let member_service = Arc::new(MemberService::new(
|
|
|
|
|
member_repo.clone(),
|
|
|
|
|
room_repo.clone(),
|
|
|
|
|
permission_service.clone(),
|
|
|
|
|
NotificationService::default(),
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
// Get owner user ID
|
|
|
|
|
let operator_id = get_creator_user_id(pool, &room.id).await;
|
|
|
|
|
|
|
|
|
|
// Use barrier for concurrent updates
|
|
|
|
|
let barrier = Arc::new(Barrier::new(10));
|
|
|
|
|
let room_id = room.id;
|
|
|
|
|
|
|
|
|
|
// 10 concurrent updates to the same member
|
|
|
|
|
let mut handles = Vec::with_capacity(10);
|
|
|
|
|
let permission_updates = [
|
|
|
|
|
RoomAdminPermissionBits::SEND_CHAT_MESSAGES,
|
|
|
|
|
RoomAdminPermissionBits::MANAGE_OWN_MEDIA,
|
|
|
|
|
RoomAdminPermissionBits::BROWSE_LIBRARY,
|
|
|
|
|
RoomAdminPermissionBits::VIEW_MEMBERS,
|
|
|
|
|
RoomAdminPermissionBits::VIEW_CHAT_HISTORY,
|
|
|
|
|
RoomAdminPermissionBits::USE_VOICE_CHAT,
|
|
|
|
|
RoomAdminPermissionBits::SEND_CHAT_MESSAGES | RoomAdminPermissionBits::USE_VOICE_CHAT,
|
|
|
|
|
RoomAdminPermissionBits::MANAGE_OWN_MEDIA | RoomAdminPermissionBits::BROWSE_LIBRARY,
|
|
|
|
|
RoomAdminPermissionBits::VIEW_MEMBERS | RoomAdminPermissionBits::VIEW_CHAT_HISTORY,
|
|
|
|
|
RoomAdminPermissionBits::SEND_CHAT_MESSAGES | RoomAdminPermissionBits::MANAGE_OWN_MEDIA,
|
|
|
|
|
];
|
|
|
|
|
for permission in permission_updates {
|
|
|
|
|
let barrier_clone = barrier.clone();
|
|
|
|
|
let member_service_clone = member_service.clone();
|
|
|
|
|
let room_id_clone = room_id;
|
|
|
|
|
let target_id = target_user.id;
|
|
|
|
|
|
|
|
|
|
let handle = tokio::spawn(async move {
|
|
|
|
|
barrier_clone.wait().await;
|
|
|
|
|
|
|
|
|
|
// Each task sets different permissions (using raw u64)
|
|
|
|
|
member_service_clone
|
|
|
|
|
.set_member_permissions(room_id_clone, operator_id, target_id, permission, 0)
|
|
|
|
|
.await
|
|
|
|
|
});
|
|
|
|
|
handles.push(handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect results
|
|
|
|
|
let mut success_count = 0;
|
|
|
|
|
let mut retry_exhausted = 0;
|
|
|
|
|
let mut unexpected_errors = Vec::new();
|
|
|
|
|
|
|
|
|
|
for handle in handles {
|
|
|
|
|
match handle.await.checked("task should complete") {
|
|
|
|
|
Ok(_) => success_count += 1,
|
|
|
|
|
Err(Error::Internal(msg)) if msg.contains("retry") || msg.contains("maximum") => {
|
|
|
|
|
retry_exhausted += 1;
|
|
|
|
|
}
|
|
|
|
|
Err(e) => unexpected_errors.push(e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
unexpected_errors.is_empty(),
|
|
|
|
|
"Unexpected permission update errors: {unexpected_errors:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// With retry mechanism, most operations should succeed
|
|
|
|
|
assert!(
|
|
|
|
|
success_count >= 5,
|
|
|
|
|
"At least 5 updates should succeed with retry"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
tracing::info!(
|
|
|
|
|
"Optimistic lock retry test: {} succeeded, {} exhausted retries",
|
|
|
|
|
success_count,
|
|
|
|
|
retry_exhausted
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test optimistic lock conflict on role update.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_optimistic_lock_conflict_on_role_update() {
|
|
|
|
|
let infra = create_test_pool().await;
|
|
|
|
|
let pool = &infra.pool;
|
|
|
|
|
|
|
|
|
|
let (_owner, room, _settings) = setup_test_room(pool, "Role Lock Room", 100).await;
|
|
|
|
|
|
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
|
|
|
|
|
|
|
|
let target_user = user_repo
|
|
|
|
|
.create(&make_user("role_target"))
|
|
|
|
|
.await
|
|
|
|
|
.checked("target user should be created");
|
|
|
|
|
let target_member = RoomMember::new(room.id, target_user.id, RoomRole::Member);
|
|
|
|
|
member_repo
|
|
|
|
|
.add(&target_member)
|
|
|
|
|
.await
|
|
|
|
|
.checked("member should be added");
|
|
|
|
|
|
|
|
|
|
// Get owner user ID
|
|
|
|
|
let operator_id = get_creator_user_id(pool, &room.id).await;
|
|
|
|
|
|
|
|
|
|
// Concurrently update the same member's role (Creator -> Admin, then Admin -> Member)
|
|
|
|
|
// Use a version bumper to simulate conflicts
|
|
|
|
|
let room_id = room.id;
|
|
|
|
|
let target_id = target_user.id;
|
|
|
|
|
let pool_clone = pool.clone();
|
|
|
|
|
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
|
|
|
let stop_clone = stop.clone();
|
|
|
|
|
|
|
|
|
|
let bumper = tokio::spawn(async move {
|
|
|
|
|
while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
|
|
|
|
|
sqlx::query!(
|
|
|
|
|
"UPDATE room_members SET version = version + 1 WHERE room_id = $1 AND user_id = $2",
|
|
|
|
|
room_id.as_i64(),
|
|
|
|
|
target_id.as_i64()
|
|
|
|
|
)
|
|
|
|
|
.execute(&pool_clone)
|
|
|
|
|
.await?;
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
|
|
|
|
|
}
|
|
|
|
|
Ok::<_, sqlx::Error>(())
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Setup services
|
|
|
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
|
let room_settings_repo = RoomSettingsRepository::new(pool.clone());
|
|
|
|
|
let permission_service =
|
|
|
|
|
PermissionService::new(member_repo.clone(), room_repo.clone(), None, 1000, 300)
|
|
|
|
|
.checked("permission service should build");
|
|
|
|
|
let member_service = MemberService::new_with_runtime(
|
|
|
|
|
member_repo.clone(),
|
|
|
|
|
room_repo.clone(),
|
|
|
|
|
Some(room_settings_repo),
|
|
|
|
|
permission_service.clone(),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
NotificationService::default(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let result = member_service
|
|
|
|
|
.set_member_role(room.id, operator_id, target_user.id, RoomRole::Admin)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
|
|
|
bumper
|
|
|
|
|
.await
|
|
|
|
|
.checked("bumper task should not panic")
|
|
|
|
|
.checked("bumper task should update member versions");
|
|
|
|
|
|
|
|
|
|
// Either succeeds (retry worked) or returns Internal (retry exhaustion)
|
|
|
|
|
match result {
|
|
|
|
|
Ok(_) => {}
|
|
|
|
|
Err(Error::Internal(msg)) => {
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("retry") || msg.contains("maximum"),
|
|
|
|
|
"Should mention retry exhaustion: {msg}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Err(other) => {
|
|
|
|
|
std::panic::panic_any(format!("unexpected role update error: {other:?}"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test concurrent leave and rejoin operations.
|
|
|
|
|
///
|
|
|
|
|
/// Scenario:
|
|
|
|
|
/// 1. Create room with creator and members
|
|
|
|
|
/// 2. Some members leave, some new members join concurrently
|
|
|
|
|
/// 3. Verify member count remains consistent
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
#[ignore = "Requires Docker"]
|
|
|
|
|
async fn test_concurrent_leave_and_rejoin() {
|
|
|
|
|
let infra = create_test_pool().await;
|
|
|
|
|
let pool = &infra.pool;
|
|
|
|
|
|
|
|
|
|
// Room with capacity 20
|
|
|
|
|
let (_owner, room, _settings) = setup_test_room(pool, "Leave Rejoin Room", 20).await;
|
|
|
|
|
|
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
|
|
|
let mut initial_members = Vec::with_capacity(10);
|
|
|
|
|
|
|
|
|
|
for i in 0..10 {
|
|
|
|
|
let user = user_repo
|
|
|
|
|
.create(&make_user(&format!("initial_{i}")))
|
|
|
|
|
.await
|
|
|
|
|
.checked("user should be created");
|
|
|
|
|
let member = RoomMember::new(room.id, user.id, RoomRole::Member);
|
|
|
|
|
member_repo
|
|
|
|
|
.add(&member)
|
|
|
|
|
.await
|
|
|
|
|
.checked("member should be added");
|
|
|
|
|
initial_members.push(user);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Setup services
|
|
|
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
|
let room_settings_repo = RoomSettingsRepository::new(pool.clone());
|
|
|
|
|
let permission_service =
|
|
|
|
|
PermissionService::new(member_repo.clone(), room_repo.clone(), None, 1000, 300)
|
|
|
|
|
.checked("permission service should build");
|
|
|
|
|
let member_service = MemberService::new_with_runtime(
|
|
|
|
|
member_repo.clone(),
|
|
|
|
|
room_repo.clone(),
|
|
|
|
|
Some(room_settings_repo),
|
|
|
|
|
permission_service.clone(),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
NotificationService::default(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let mut new_users = Vec::with_capacity(10);
|
|
|
|
|
for i in 0..10 {
|
|
|
|
|
let user = user_repo
|
|
|
|
|
.create(&make_user(&format!("new_joiner_{i}")))
|
|
|
|
|
.await
|
|
|
|
|
.checked("user should be created");
|
|
|
|
|
new_users.push(user);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use barrier for synchronization
|
|
|
|
|
let barrier = Arc::new(Barrier::new(20));
|
|
|
|
|
let room_id = room.id;
|
|
|
|
|
|
|
|
|
|
// 10 leave + 10 join = 20 concurrent operations
|
|
|
|
|
let mut leave_handles = Vec::with_capacity(10);
|
|
|
|
|
let mut join_handles = Vec::with_capacity(10);
|
|
|
|
|
|
|
|
|
|
// Leave operations
|
|
|
|
|
for user in initial_members {
|
|
|
|
|
let member_repo_clone = member_repo.clone();
|
|
|
|
|
let barrier_clone = barrier.clone();
|
|
|
|
|
let room_id_clone = room_id;
|
|
|
|
|
|
|
|
|
|
let handle = tokio::spawn(async move {
|
|
|
|
|
barrier_clone.wait().await;
|
|
|
|
|
member_repo_clone.remove(&room_id_clone, &user.id).await
|
|
|
|
|
});
|
|
|
|
|
leave_handles.push(handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Join operations
|
|
|
|
|
for user in new_users {
|
|
|
|
|
let member_service_clone = member_service.clone();
|
|
|
|
|
let barrier_clone = barrier.clone();
|
|
|
|
|
let room_id_clone = room_id;
|
|
|
|
|
|
|
|
|
|
let handle = tokio::spawn(async move {
|
|
|
|
|
barrier_clone.wait().await;
|
refactor: remove low-value thin wrappers (#426)
## Summary
- remove unused compatibility APIs and low-value forwarding wrappers
across the workspace
- inline service, repository, provider, cluster, proxy, and protocol
paths while preserving existing behavior
- remove stale dependencies, metrics, tests, and SQLx metadata generated
for deleted queries
## Validation
- `cargo check --workspace --all-targets`
- `cargo fmt --all -- --check`
- `git diff --check`
- relevant test suites: 1,408 passed, 184 ignored
- broader test run: 2,286 passed
1 month ago
|
|
|
let options = AddMemberOptions::default().with_max_members(0);
|
|
|
|
|
member_service_clone
|
|
|
|
|
.add_member_with_options(room_id_clone, user.id, RoomRole::Member, options)
|
|
|
|
|
.await
|
|
|
|
|
});
|
|
|
|
|
join_handles.push(handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect results
|
|
|
|
|
let mut leave_success = 0;
|
|
|
|
|
let mut join_success = 0;
|
|
|
|
|
|
|
|
|
|
for handle in leave_handles {
|
|
|
|
|
match handle.await.checked("leave task should complete") {
|
|
|
|
|
Ok(_) => leave_success += 1,
|
|
|
|
|
Err(e) => tracing::warn!("Leave operation failed: {:?}", e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for handle in join_handles {
|
|
|
|
|
match handle.await.checked("join task should complete") {
|
|
|
|
|
Ok(_) => join_success += 1,
|
|
|
|
|
Err(e) => tracing::warn!("Join operation failed: {:?}", e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// All operations should succeed
|
|
|
|
|
assert_eq!(leave_success, 10, "All leave operations should succeed");
|
|
|
|
|
assert_eq!(join_success, 10, "All join operations should succeed");
|
|
|
|
|
|
|
|
|
|
// Final count: owner (1) + 10 new = 11
|
|
|
|
|
let final_count: i64 = sqlx::query_scalar!(
|
|
|
|
|
r#"SELECT COUNT(*) AS "count!" FROM room_members WHERE room_id = $1"#,
|
|
|
|
|
room.id.as_i64()
|
|
|
|
|
)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.checked("members should be counted");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
final_count, 11,
|
|
|
|
|
"Final member count should be 11 (owner + 10 new)"
|
|
|
|
|
);
|
|
|
|
|
}
|