You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/synctv-core/tests/chat_service_full_tests.rs

3095 lines
100 KiB
Rust

//! `ChatService` integration tests
//!
//! Tests `send_message` permission check, `chat_enabled` setting, rate limit mapping,
//! and `delete_message` permission logic with real `PostgreSQL`
//! via testcontainers.
//!
use std::sync::Arc;
use chrono::Utc;
use image::ImageEncoder;
use sha2::Digest;
use sqlx::PgPool;
use synctv_core::{
cache::{KeyBuilder, UsernameCache},
models::{
room_settings::ChatEnabled, AuditAction, AuditTargetType, ChatEventKind,
ChatMemberJoinedMetadata, ChatMessage, ChatMessageSelection, ChatMessageStatus,
ChatMessageType, ChatMetadata, DeleteChatMessage, EditChatMessage, FileBlobCompression,
FileReferenceTarget, FileUploadManifestPart, FileUploadSessionCreateResult, NewStoredFile,
RoomAdminPermissionBits, RoomMemberPermissionBits, RoomRole, RoomSettings, SendChatMessage,
SubmittedFileReference, User, UserId, UserRole, UserStatus,
},
repository::{
ChatRepository, FileStorageRepository, RoomMemberRepository, RoomRepository,
RoomSettingsRepository, UpsertFileBlob, UpsertFileObject, UserRepository,
},
service::{
AuditService, BruteForceProtection, ChatDependencies, ChatRuntime, ChatService,
ContentFilter, DisabledFileStorageService, FileStorageCleanupOrigin, FileStorageService,
InMemoryTokenBlacklistStore, JwtService, NotificationService, PermissionService,
RateLimitConfig, RateLimiter, RequestRateLimiterService, RoomService, RoomSettingsService,
UserService,
},
Error,
};
use synctv_core_testing::{create_test_pool, TestOptionExt, TestResultExt};
fn png_test_image() -> Vec<u8> {
let mut out = Vec::new();
image::codecs::png::PngEncoder::new(&mut out)
.write_image(&[0, 0, 0, 255], 1, 1, image::ColorType::Rgba8.into())
.expect("test png image should encode");
out
}
fn make_user_service(pool: &PgPool) -> UserService {
make_user_service_with_username_cache(
pool,
UsernameCache::local_only("test:username:".to_string(), 100, 60),
)
}
fn make_user_service_with_username_cache(
pool: &PgPool,
username_cache: UsernameCache,
) -> UserService {
let secret = "Test_Secret_Key_For_JWT_Tokens_32Bytes!!";
let jwt_service = JwtService::new(secret).checked("Failed to create JwtService");
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_chat_service_with_config(
pool: &PgPool,
rate_limit_config: RateLimitConfig,
) -> (ChatService, UsernameCache) {
make_chat_service_with_options(
pool,
rate_limit_config,
None,
Arc::new(DisabledFileStorageService),
)
}
fn make_chat_service_with_config_and_storage(
pool: &PgPool,
rate_limit_config: RateLimitConfig,
file_storage_service: Arc<dyn FileStorageService>,
) -> (ChatService, UsernameCache) {
make_chat_service_with_options(pool, rate_limit_config, None, file_storage_service)
}
fn make_chat_service_with_config_and_audit(
pool: &PgPool,
rate_limit_config: RateLimitConfig,
audit_service: Option<Arc<AuditService>>,
) -> (ChatService, UsernameCache) {
make_chat_service_with_options(
pool,
rate_limit_config,
audit_service,
Arc::new(DisabledFileStorageService),
)
}
fn make_chat_service_with_options(
pool: &PgPool,
rate_limit_config: RateLimitConfig,
audit_service: Option<Arc<AuditService>>,
file_storage_service: Arc<dyn FileStorageService>,
) -> (ChatService, UsernameCache) {
let chat_repo = Arc::new(ChatRepository::new(pool.clone()));
let rate_limiter: Arc<dyn RequestRateLimiterService> =
Arc::new(RateLimiter::local_only("test:chat:".to_string()));
let content_filter = ContentFilter::new();
let username_cache = UsernameCache::local_only("test:username:".to_string(), 100, 60);
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_with_runtime(
member_repo,
room_repo,
synctv_core::service::PermissionServiceRuntime {
room_settings_repo: Some(room_settings_repo.clone()),
..synctv_core::service::PermissionServiceRuntime::local_only()
},
)
.checked("permission service should build");
let notification_service = NotificationService::default();
let room_settings_service = RoomSettingsService::new(
room_settings_repo,
None,
Arc::new(notification_service.clone()),
None,
None,
);
let service = ChatService::new(
chat_repo,
ChatRuntime {
clock: Arc::new(synctv_core::SystemClock),
rate_limiter,
rate_limit_config,
content_filter,
},
ChatDependencies {
permission_service,
room_settings_service,
user_service: Arc::new(make_user_service_with_username_cache(
pool,
username_cache.clone(),
)),
file_storage_service,
audit_service,
notification_service,
runtime_settings_store: None,
},
);
(service, username_cache)
}
fn make_chat_service(pool: &PgPool) -> (ChatService, UsernameCache) {
make_chat_service_with_config(pool, RateLimitConfig::default())
}
fn send_user_chat_request(
room_id: synctv_core::models::RoomId,
user_id: UserId,
client_message_id: &str,
content: &str,
) -> SendChatMessage {
SendChatMessage {
room_id,
user_id,
client_message_id: Some(client_message_id.to_string()),
content: content.to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
}
}
async fn insert_member_joined_chat_event(
chat_repo: &ChatRepository,
room_id: synctv_core::models::RoomId,
target: &User,
actor: &User,
event_id: &str,
) -> synctv_core::models::ChatMessageEventLog {
let mut message = ChatMessage::new(
room_id,
target.id,
format!("{} joined the room", target.username),
);
message.message_type = ChatMessageType::SystemMemberJoined;
message.metadata = Some(ChatMetadata::MemberJoined(ChatMemberJoinedMetadata {
user_id: target.id,
username: target.username.clone(),
actor_user_id: Some(actor.id),
actor_username: Some(actor.username.clone()),
role: RoomRole::Member,
}));
chat_repo
.insert_message_event(&message, &[], &[], actor.id, event_id, Utc::now())
.await
.checked("member joined chat event should be inserted")
}
fn make_chat_service_with_database_storage(pool: &PgPool) -> (ChatService, UsernameCache) {
make_chat_service_with_config_and_storage(
pool,
RateLimitConfig::default(),
Arc::new(synctv_core::service::DatabaseFileStorageService::new(
"database",
Arc::new(FileStorageRepository::new(pool.clone())),
"test-file-storage-secret",
)),
)
}
async fn upload_chat_attachment_file(
chat_service: &ChatService,
session: &synctv_core::models::FileUploadSession,
payload: Vec<u8>,
) {
let encoded_object_key = session
.upload_object_access
.as_ref()
.map(|endpoint| endpoint.encoded_object_key.as_str())
.checked("database upload endpoint should be returned");
let upload_token = session
.upload_headers
.get(synctv_core::service::FILE_UPLOAD_TOKEN_HEADER)
.checked("database upload token header should be returned");
let content_type = session
.file
.mime_type
.as_deref()
.checked("attachment session mime_type should be present");
chat_service
.store_attachment_upload_object(
encoded_object_key,
upload_token,
Some(content_type),
None,
payload.into(),
)
.await
.checked("database attachment object should store");
}
fn manifest_parts_from_payload(
payload: &[u8],
plan: &synctv_core::models::FileUploadPlan,
) -> Vec<FileUploadManifestPart> {
plan.parts
.iter()
.map(|part| {
let start = usize::try_from(part.offset_bytes).checked("part offset should fit");
let len = usize::try_from(part.size_bytes).checked("part size should fit");
let end = start.checked_add(len).checked("part range should fit");
FileUploadManifestPart {
part_number: part.part_number,
offset_bytes: part.offset_bytes,
size_bytes: part.size_bytes,
checksum_sha256: hex::encode(sha2::Sha256::digest(&payload[start..end])),
}
})
.collect()
}
async fn create_chat_attachment_upload_session_for_payload(
chat_service: &ChatService,
mut request: synctv_core::models::CreateChatAttachmentUploadSession,
payload: &[u8],
) -> synctv_core::models::FileUploadSession {
request.parts = Vec::new();
let plan = match chat_service
.create_attachment_upload_session(request.clone())
.await
.checked("chat attachment upload plan should be returned")
{
FileUploadSessionCreateResult::Plan(plan) => plan,
FileUploadSessionCreateResult::Session(_) => {
panic!("chat attachment upload should return a plan before manifest parts")
}
};
request.parts = manifest_parts_from_payload(payload, &plan);
match chat_service
.create_attachment_upload_session(request)
.await
.checked("chat attachment upload session should be returned")
{
FileUploadSessionCreateResult::Session(session) => session,
FileUploadSessionCreateResult::Plan(_) => {
panic!("chat attachment upload should return a session after manifest parts")
}
}
}
fn submitted_file_reference(file: &NewStoredFile) -> SubmittedFileReference {
synctv_core::service::submitted_file_reference_from_session_file(file)
.checked("submitted file reference 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,
}
}
fn checked_idempotent_insert_event(
result: synctv_core::Result<synctv_core::repository::chat::IdempotentChatEventInsert>,
) -> synctv_core::models::ChatMessageEventLog {
match result {
Ok(insert) => insert.event,
Err(error) => std::panic::panic_any(format!(
"idempotent chat event insert should succeed: {error:?}"
)),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_send_message_without_chat_permission_denied() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service_with_database_storage(&pool);
let creator = user_repo
.create(&make_user("chat_perm_creator"))
.await
.checked("test operation should succeed");
let member = user_repo
.create(&make_user("chat_perm_member"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
username_cache
.set(&member.id, &member.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Chat Perm Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, member.id, None)
.await
.checked("test operation should succeed");
// Revoke CHAT permission from the member
room_service
.member_service()
.revoke_permission(
room.id,
creator.id,
member.id,
RoomMemberPermissionBits::SEND_CHAT_MESSAGES,
)
.await
.checked("test operation should succeed");
let result = chat_service
.send_message(room.id, member.id, "Hello".to_string())
.await;
assert!(
result.is_err(),
"send_message should fail without CHAT permission"
);
match result.failed("operation should fail") {
Error::Authorization(_) => {}
other => std::panic::panic_any(format!("expected Authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_send_message_chat_disabled_rejected() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("chatdis_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let settings = RoomSettings {
chat_enabled: ChatEnabled(false),
..Default::default()
};
let (room, _) = room_service
.create_room(
"Chat Disabled".to_string(),
String::new(),
creator.id,
None,
Some(settings),
)
.await
.checked("test operation should succeed");
// Creator has all permissions, but chat is disabled for the room
let result = chat_service
.send_message(room.id, creator.id, "Hello".to_string())
.await;
assert!(
result.is_err(),
"send_message should fail when chat is disabled"
);
match result.failed("operation should fail") {
Error::Authorization(msg) => {
assert!(
msg.contains("disabled") || msg.contains("Chat"),
"Error should mention chat disabled: {msg}"
);
}
other => std::panic::panic_any(format!("expected Authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_send_message_rate_limit_triggers() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let creator = user_repo
.create(&make_user("chatrl_creator"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Chat RL Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
// Build chat service with very restrictive rate limit (1 msg/sec, 1 sec window)
let chat_repo = Arc::new(ChatRepository::new(pool.clone()));
let rate_limiter: Arc<dyn RequestRateLimiterService> =
Arc::new(RateLimiter::local_only("test:chatrl:".to_string()));
let rate_limit_config = RateLimitConfig {
chat_per_second: 1,
window_seconds: 1,
};
let content_filter = ContentFilter::new();
let username_cache = UsernameCache::local_only("test:username:".to_string(), 100, 60);
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
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_with_runtime(
member_repo,
room_repo,
synctv_core::service::PermissionServiceRuntime {
room_settings_repo: Some(room_settings_repo.clone()),
..synctv_core::service::PermissionServiceRuntime::local_only()
},
)
.checked("permission service should build");
let notification_service = Arc::new(NotificationService::default());
let room_settings_service =
RoomSettingsService::new(room_settings_repo, None, notification_service, None, None);
let chat_service = ChatService::new(
chat_repo,
ChatRuntime {
clock: Arc::new(synctv_core::SystemClock),
rate_limiter,
rate_limit_config,
content_filter,
},
ChatDependencies {
permission_service,
room_settings_service,
user_service: Arc::new(make_user_service_with_username_cache(&pool, username_cache)),
file_storage_service: Arc::new(DisabledFileStorageService),
audit_service: None,
notification_service: NotificationService::default(),
runtime_settings_store: None,
},
);
let mut rate_limited = false;
for i in 0..20 {
let result = chat_service
.send_message(room.id, creator.id, format!("msg{i}"))
.await;
if let Err(Error::RateLimited(_)) = &result {
rate_limited = true;
break;
}
}
assert!(rate_limited, "Should hit rate limit after rapid messages");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_message_owner_can_delete_own() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("delmsg_owner"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Del Msg Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let msg = chat_service
.send_message(room.id, creator.id, "Delete me".to_string())
.await
.checked("test operation should succeed");
// Owner should be able to delete their own message
let result = chat_service
.delete_message(&room.id, msg.id, &creator.id)
.await;
assert!(
result.is_ok(),
"Owner should be able to delete their own message"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_message_non_owner_requires_delete_chat_messages_permission() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("delmsg_creator"))
.await
.checked("test operation should succeed");
let member = user_repo
.create(&make_user("delmsg_member"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
username_cache
.set(&member.id, &member.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Del Msg Perm Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, member.id, None)
.await
.checked("test operation should succeed");
// Creator sends a message
let msg = chat_service
.send_message(room.id, creator.id, "Protected msg".to_string())
.await
.checked("test operation should succeed");
// Member (non-owner without DELETE_CHAT_MESSAGES) tries to delete -- should fail
let result = chat_service
.delete_message(&room.id, msg.id, &member.id)
.await;
assert!(
result.is_err(),
"Non-owner without DELETE_CHAT_MESSAGES should be denied"
);
match result.failed("operation should fail") {
Error::Authorization(_) => {}
other => std::panic::panic_any(format!("expected Authorization error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_message_non_owner_with_delete_chat_messages_succeeds() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("delmsg2_creator"))
.await
.checked("test operation should succeed");
let admin = user_repo
.create(&make_user("delmsg2_admin"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
username_cache
.set(&admin.id, &admin.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Del Msg Admin Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, admin.id, None)
.await
.checked("test operation should succeed");
room_service
.member_service()
.set_member_role(
room.id,
creator.id,
admin.id,
synctv_core::models::RoomRole::Admin,
)
.await
.checked("test operation should succeed");
// Grant DELETE_CHAT_MESSAGES permission to admin
room_service
.member_service()
.grant_permission(
room.id,
creator.id,
admin.id,
RoomAdminPermissionBits::DELETE_CHAT_MESSAGES,
)
.await
.checked("test operation should succeed");
// Creator sends a message
let msg = chat_service
.send_message(room.id, creator.id, "Deletable msg".to_string())
.await
.checked("test operation should succeed");
// Admin (with DELETE_CHAT_MESSAGES) can delete another user's message
let result = chat_service
.delete_message(&room.id, msg.id, &admin.id)
.await;
assert!(
result.is_ok(),
"Non-owner with DELETE_CHAT_MESSAGES should be able to delete"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_admin_delete_records_actor_reason_and_original_author() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let audit_service = Arc::new(AuditService::new_unbuffered(pool.clone()));
let (chat_service, username_cache) = make_chat_service_with_config_and_audit(
&pool,
RateLimitConfig::default(),
Some(audit_service),
);
let creator = user_repo
.create(&make_user("admin_delete_author"))
.await
.checked("test operation should succeed");
let admin = user_repo
.create(&make_user("admin_delete_moderator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
username_cache
.set(&admin.id, &admin.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Admin Delete Audit Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, admin.id, None)
.await
.checked("test operation should succeed");
room_service
.member_service()
.set_member_role(
room.id,
creator.id,
admin.id,
synctv_core::models::RoomRole::Admin,
)
.await
.checked("test operation should succeed");
room_service
.member_service()
.grant_permission(
room.id,
creator.id,
admin.id,
RoomAdminPermissionBits::DELETE_CHAT_MESSAGES,
)
.await
.checked("test operation should succeed");
let msg = chat_service
.send_message(room.id, creator.id, "moderate me".to_string())
.await
.checked("test operation should succeed");
let deleted = chat_service
.delete_message_event(DeleteChatMessage {
room_id: room.id,
message_id: msg.id,
user_id: admin.id,
client_operation_id: Some("admin-delete-op".to_string()),
reason: Some("policy violation".to_string()),
expected_version: Some(msg.version),
})
.await
.checked("test operation should succeed");
assert_eq!(deleted.actor_user_id, admin.id);
assert_eq!(deleted.message.message.user_id, Some(creator.id));
assert_eq!(deleted.message.message.deleted_by, Some(admin.id));
assert_eq!(
deleted.message.message.delete_reason.as_deref(),
Some("policy violation")
);
let audit_row = sqlx::query!(
r#"
SELECT actor_username AS "actor_username!",
target_type,
target_id,
details AS "details!: serde_json::Value"
FROM audit_logs
WHERE actor_id = $1 AND action = $2
"#,
admin.id.as_i64(),
AuditAction::ChatMessageDeleted.as_i16()
)
.fetch_one(&pool)
.await
.checked("test operation should succeed");
assert_eq!(audit_row.actor_username, admin.username);
assert_eq!(
audit_row
.target_type
.map(|value| AuditTargetType::try_from(value).checked("test operation should succeed")),
Some(AuditTargetType::ChatMessage)
);
assert_eq!(audit_row.target_id, Some(format!("{}:{}", room.id, msg.id)));
let expected_room_id = room.id.to_string();
let expected_creator_id = creator.id.to_string();
let expected_admin_id = admin.id.to_string();
assert_eq!(
audit_row.details["roomId"].as_str(),
Some(expected_room_id.as_str())
);
assert_eq!(
audit_row.details["messageId"].as_str(),
Some(msg.id.to_string().as_str())
);
assert_eq!(
audit_row.details["originalAuthorId"].as_str(),
Some(expected_creator_id.as_str())
);
assert_eq!(
audit_row.details["deletedBy"].as_str(),
Some(expected_admin_id.as_str())
);
assert_eq!(
audit_row.details["reason"].as_str(),
Some("policy violation")
);
assert_eq!(
audit_row.details["eventId"].as_str(),
Some(deleted.event_id.as_str())
);
assert_eq!(
audit_row.details["clientOperationId"].as_str(),
Some("admin-delete-op")
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_send_message_broadcasts_to_room_members() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let creator = user_repo
.create(&make_user("chat_broadcast_creator"))
.await
.checked("test operation should succeed");
let chat_repo = Arc::new(ChatRepository::new(pool.clone()));
let rate_limiter: Arc<dyn RequestRateLimiterService> =
Arc::new(RateLimiter::local_only("test:chat_broadcast:".to_string()));
let rate_limit_config = RateLimitConfig::default();
let content_filter = ContentFilter::new();
let username_cache = UsernameCache::local_only("test:username:".to_string(), 100, 60);
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
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_with_runtime(
member_repo,
room_repo,
synctv_core::service::PermissionServiceRuntime {
room_settings_repo: Some(room_settings_repo.clone()),
..synctv_core::service::PermissionServiceRuntime::local_only()
},
)
.checked("permission service should build");
let notification_service = NotificationService::default();
let mut notification_rx = notification_service.subscribe();
let room_settings_service = RoomSettingsService::new(
room_settings_repo,
None,
Arc::new(notification_service.clone()),
None,
None,
);
let chat_service = ChatService::new(
chat_repo,
ChatRuntime {
clock: Arc::new(synctv_core::SystemClock),
rate_limiter,
rate_limit_config,
content_filter,
},
ChatDependencies {
permission_service,
room_settings_service,
user_service: Arc::new(make_user_service_with_username_cache(&pool, username_cache)),
file_storage_service: Arc::new(DisabledFileStorageService),
audit_service: None,
notification_service,
runtime_settings_store: None,
},
);
let (room, _) = room_service
.create_room(
"Broadcast Test Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let msg = chat_service
.send_message(room.id, creator.id, "Hello, world!".to_string())
.await
.checked("send_message should succeed");
let (event_room_id, event) =
tokio::time::timeout(std::time::Duration::from_secs(2), notification_rx.recv())
.await
.checked("chat notification should arrive")
.checked("notification channel should remain open");
assert_eq!(event_room_id, room.id);
assert_eq!(
event.event_type(),
"chat_message",
"Event type should be chat_message"
);
assert!(msg.id > 0, "Message should have a positive ID");
assert_eq!(msg.content, "Hello, world!", "Message content should match");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_history_cursor_pagination_basic() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("cursor_user"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Cursor Pagination Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let mut sent_messages = Vec::new();
for i in 0..5 {
let msg = chat_service
.send_message(room.id, creator.id, format!("message_{i}"))
.await
.checked("test operation should succeed");
sent_messages.push(msg);
// Small delay to ensure ordering by timestamp
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
// Page 1: Get first 2 messages (newest first, no cursor)
let (page1, cursor1) = chat_service
.get_history(&room.id, None, 2)
.await
.checked("get_history page 1 should succeed");
assert_eq!(page1.len(), 2, "Page 1 should have 2 messages");
assert!(
cursor1.is_some(),
"Should have next cursor when more messages exist"
);
// Newest messages first: message_4, message_3
assert_eq!(
page1[0].content, "message_4",
"First message should be newest"
);
assert_eq!(
page1[1].content, "message_3",
"Second message should be second newest"
);
// Page 2: Get next 2 messages using cursor
let cursor1_val = cursor1.checked("test operation should succeed");
let (page2, cursor2) = chat_service
.get_history(&room.id, Some((cursor1_val.0, cursor1_val.1)), 2)
.await
.checked("get_history page 2 should succeed");
assert_eq!(page2.len(), 2, "Page 2 should have 2 messages");
assert!(
cursor2.is_some(),
"Should have next cursor (1 more message)"
);
assert_eq!(page2[0].content, "message_2", "Page 2 first message");
assert_eq!(page2[1].content, "message_1", "Page 2 second message");
// Page 3: Get last message
let cursor2_val = cursor2.checked("test operation should succeed");
let (page3, cursor3) = chat_service
.get_history(&room.id, Some((cursor2_val.0, cursor2_val.1)), 2)
.await
.checked("get_history page 3 should succeed");
assert_eq!(page3.len(), 1, "Page 3 should have 1 message (last page)");
assert!(cursor3.is_none(), "No next cursor on last page");
assert_eq!(
page3[0].content, "message_0",
"Page 3 should have oldest message"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_history_empty_room() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, _username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("empty_room_user"))
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Empty Chat Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
// Get history from room with no messages
let (history, cursor) = chat_service
.get_history(&room.id, None, 10)
.await
.checked("get_history should succeed for empty room");
assert!(
history.is_empty(),
"History should be empty for room with no messages"
);
assert!(cursor.is_none(), "No cursor when room is empty");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_history_single_page() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("single_page_user"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Single Page Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
for i in 0..3 {
chat_service
.send_message(room.id, creator.id, format!("msg_{i}"))
.await
.checked("test operation should succeed");
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
// Get history with limit larger than message count
let (history, cursor) = chat_service
.get_history(&room.id, None, 100)
.await
.checked("get_history should succeed");
assert_eq!(history.len(), 3, "Should get all 3 messages");
assert!(
cursor.is_none(),
"No cursor when all messages fit in one page"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_history_limit_capped_at_100() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
// Use a high rate limit config to avoid hitting rate limits during bulk insert
let rate_limit_config = RateLimitConfig {
chat_per_second: 200,
window_seconds: 1,
};
let (chat_service, username_cache) = make_chat_service_with_config(&pool, rate_limit_config);
let creator = user_repo
.create(&make_user("limit_cap_user"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Limit Cap Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
for i in 0..105 {
chat_service
.send_message(room.id, creator.id, format!("msg_{i}"))
.await
.checked("test operation should succeed");
}
// Request with limit=200, should be capped at 100
let (history, cursor) = chat_service
.get_history(&room.id, None, 200)
.await
.checked("get_history should succeed");
assert_eq!(history.len(), 100, "Should be capped at 100 messages");
assert!(
cursor.is_some(),
"Should have cursor since there are more messages"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_history_messages_from_correct_room() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("room_isolation_user"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room1, _) = room_service
.create_room("Room 1".to_string(), String::new(), creator.id, None, None)
.await
.checked("test operation should succeed");
let (room2, _) = room_service
.create_room("Room 2".to_string(), String::new(), creator.id, None, None)
.await
.checked("test operation should succeed");
chat_service
.send_message(room1.id, creator.id, "room1_message".to_string())
.await
.checked("test operation should succeed");
chat_service
.send_message(room2.id, creator.id, "room2_message".to_string())
.await
.checked("test operation should succeed");
// Get history from room1 should only return room1 messages
let (history1, _) = chat_service
.get_history(&room1.id, None, 10)
.await
.checked("get_history for room1 should succeed");
assert_eq!(history1.len(), 1, "Room1 should have 1 message");
assert_eq!(
history1[0].content, "room1_message",
"Room1 history should only contain room1 messages"
);
// Get history from room2 should only return room2 messages
let (history2, _) = chat_service
.get_history(&room2.id, None, 10)
.await
.checked("get_history for room2 should succeed");
assert_eq!(history2.len(), 1, "Room2 should have 1 message");
assert_eq!(
history2[0].content, "room2_message",
"Room2 history should only contain room2 messages"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_chat_history_with_deleted_user_returns_none_user_id() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("deleted_user_chat_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Deleted User Chat Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let msg = chat_service
.send_message(
room.id,
creator.id,
"Message from soon-deleted user".to_string(),
)
.await
.checked("test operation should succeed");
// Verify user_id is Some before deletion
assert!(
msg.user_id.is_some(),
"user_id should be Some before user deletion"
);
// Simulate user deletion: SET NULL on user_id via raw SQL
// (foreign key ON DELETE SET NULL)
sqlx::query!(
"UPDATE chat_messages SET user_id = NULL WHERE id = $1 AND created_at = $2",
msg.id,
msg.created_at
)
.execute(&pool)
.await
.checked("test operation should succeed");
// Retrieve the message from history
let (history, _) = chat_service
.get_history(&room.id, None, 10)
.await
.checked("get_history should succeed after user deletion");
assert_eq!(history.len(), 1, "Should still have the message");
assert!(
history[0].user_id.is_none(),
"user_id should be None for deleted user's message"
);
assert_eq!(
history[0].content, "Message from soon-deleted user",
"Content should be preserved"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_send_message_oversized_content_rejected() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("oversized_msg_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Oversized Msg Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let oversized_content: String = "x".repeat(501);
let result = chat_service
.send_message(room.id, creator.id, oversized_content)
.await;
assert!(result.is_err(), "Oversized message should be rejected");
match result.failed("operation should fail") {
Error::InvalidInput(msg) => {
assert!(
msg.contains("500") || msg.contains("characters"),
"Error should mention character limit: {msg}"
);
}
other => std::panic::panic_any(format!("expected InvalidInput error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_send_message_valid_content_persisted() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("valid_msg_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Valid Msg Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let msg = chat_service
.send_message(room.id, creator.id, "Hello, valid message!".to_string())
.await
.checked("Valid message should be persisted");
assert_eq!(msg.content, "Hello, valid message!");
assert_eq!(msg.user_id, Some(creator.id));
assert_eq!(msg.room_id, room.id);
assert!(msg.id > 0);
// Verify via history
let (history, _) = chat_service
.get_history(&room.id, None, 10)
.await
.checked("test operation should succeed");
assert_eq!(history.len(), 1);
assert_eq!(history[0].content, "Hello, valid message!");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_send_message_event_idempotency_returns_existing_message() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("idempotent_chat_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Idempotent Chat Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let request = SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("client-msg-1".to_string()),
content: "same payload".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
};
let first = chat_service
.send_message_event(request.clone())
.await
.checked("test operation should succeed");
let second = chat_service
.send_message_event(request)
.await
.checked("test operation should succeed");
assert_eq!(first.event_id, second.event_id);
assert_eq!(first.message.message.id, second.message.message.id);
assert_eq!(
first.message.message.created_at,
second.message.message.created_at
);
assert_eq!(
first.message.message.client_message_id.as_deref(),
Some("client-msg-1")
);
let replay = chat_service
.get_events_after(
&room.id,
Some(&first.event_id),
10,
&ChatMessageSelection::user_default(),
)
.await
.checked("test operation should succeed");
assert!(replay.is_empty());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_chat_history_page_returns_event_cursor_for_gapless_observe() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("chat_history_cursor_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Chat History Cursor Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let empty_page = chat_service
.get_history_page_with_attachments_for_viewer(
&room.id,
None,
10,
true,
Some(&creator.id),
&ChatMessageSelection::user_default(),
)
.await
.checked("test operation should succeed");
assert_eq!(empty_page.event_cursor.sequence, 0);
assert!(empty_page.event_cursor.event_id.is_none());
let first = chat_service
.send_message_event(SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("history-cursor-1".to_string()),
content: "cursor one".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
})
.await
.checked("test operation should succeed");
let second = chat_service
.send_message_event(SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("history-cursor-2".to_string()),
content: "cursor two".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
})
.await
.checked("test operation should succeed");
let page = chat_service
.get_history_page_with_attachments_for_viewer(
&room.id,
None,
10,
true,
Some(&creator.id),
&ChatMessageSelection::user_default(),
)
.await
.checked("test operation should succeed");
assert_eq!(page.messages.len(), 2);
assert_eq!(page.event_cursor.sequence, second.sequence);
assert_eq!(
page.event_cursor.event_id.as_deref(),
Some(second.event_id.as_str())
);
let replay_from_empty = chat_service
.get_events_after_sequence(
&room.id,
empty_page.event_cursor.sequence,
10,
&ChatMessageSelection::user_default(),
)
.await
.checked("test operation should succeed");
assert_eq!(
replay_from_empty
.iter()
.map(|event| event.event.event_id.as_str())
.collect::<Vec<_>>(),
vec![first.event_id.as_str(), second.event_id.as_str()]
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_chat_history_and_events_use_include_message_types_for_system_join() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let chat_repo = ChatRepository::new(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("chat_include_cursor_creator"))
.await
.checked("test operation should succeed");
let joined = user_repo
.create(&make_user("chat_include_cursor_joined"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
username_cache
.set(&joined.id, &joined.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Chat Include Cursor Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let first = chat_service
.send_message_event(send_user_chat_request(
room.id,
creator.id,
"include-cursor-user-1",
"visible one",
))
.await
.checked("test operation should succeed");
let second = chat_service
.send_message_event(send_user_chat_request(
room.id,
creator.id,
"include-cursor-user-2",
"visible two",
))
.await
.checked("test operation should succeed");
let system_one =
insert_member_joined_chat_event(&chat_repo, room.id, &joined, &creator, "member-joined-1")
.await;
let system_two =
insert_member_joined_chat_event(&chat_repo, room.id, &joined, &creator, "member-joined-2")
.await;
let default_page = chat_service
.get_history_page_with_attachments_for_viewer(
&room.id,
None,
10,
true,
Some(&creator.id),
&ChatMessageSelection::user_default(),
)
.await
.checked("test operation should succeed");
assert_eq!(
default_page
.messages
.iter()
.map(|message| message.message.content.as_str())
.collect::<Vec<_>>(),
vec!["visible two", "visible one"]
);
assert_eq!(default_page.event_cursor.sequence, second.sequence);
assert_eq!(
default_page.event_cursor.event_id.as_deref(),
Some(second.event_id.as_str())
);
let include_system = ChatMessageSelection {
include_message_types: vec![ChatMessageType::User, ChatMessageType::SystemMemberJoined],
};
let system_page = chat_service
.get_history_page_with_attachments_for_viewer(
&room.id,
None,
10,
true,
Some(&creator.id),
&include_system,
)
.await
.checked("test operation should succeed");
assert_eq!(
system_page
.messages
.iter()
.map(|message| message.message.message_type)
.collect::<Vec<_>>(),
vec![
ChatMessageType::SystemMemberJoined,
ChatMessageType::SystemMemberJoined,
ChatMessageType::User,
ChatMessageType::User,
]
);
assert_eq!(system_page.event_cursor.sequence, system_two.sequence);
assert_eq!(
system_page.event_cursor.event_id.as_deref(),
Some(system_two.event.event_id.as_str())
);
let default_replay = chat_service
.get_events_after_sequence(
&room.id,
second.sequence,
10,
&ChatMessageSelection::user_default(),
)
.await
.checked("test operation should succeed");
assert!(default_replay.is_empty());
let system_replay = chat_service
.get_events_after_sequence(&room.id, second.sequence, 10, &include_system)
.await
.checked("test operation should succeed");
assert_eq!(
system_replay
.iter()
.map(|event| event.event.event_id.as_str())
.collect::<Vec<_>>(),
vec![
system_one.event.event_id.as_str(),
system_two.event.event_id.as_str()
]
);
let from_start_default = chat_service
.get_events_after(&room.id, Some(&first.event_id), 10, &Default::default())
.await
.checked("test operation should succeed");
assert_eq!(
from_start_default
.iter()
.map(|event| event.event.event_id.as_str())
.collect::<Vec<_>>(),
vec![second.event_id.as_str()]
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_events_after_unknown_event_id_returns_invalid_cursor() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("chat_event_cursor_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Chat Event Cursor Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let error = chat_service
.get_events_after(
&room.id,
Some("missing-chat-event"),
10,
&ChatMessageSelection::user_default(),
)
.await
.failed("unknown event cursor should be invalid input");
assert!(
matches!(error, Error::InvalidInput(message) if message == "Invalid chat event cursor")
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_send_message_event_idempotency_rejects_different_payload() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("idempotent_conflict_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Idempotent Conflict Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let mut request = SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("client-msg-conflict".to_string()),
content: "original payload".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
};
chat_service
.send_message_event(request.clone())
.await
.checked("test operation should succeed");
request.content = "changed payload".to_string();
let error = chat_service
.send_message_event(request)
.await
.failed("different idempotent payload should conflict");
match error {
Error::Conflict(message) => {
assert!(message.contains("client_message_id"));
}
other => std::panic::panic_any(format!("expected Conflict error, got: {other:?}")),
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_edit_message_increments_version_and_checks_expected_version() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("edit_version_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Edit Version Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let created = chat_service
.send_message(room.id, creator.id, "before edit".to_string())
.await
.checked("test operation should succeed");
let edited = chat_service
.edit_message(EditChatMessage {
room_id: room.id,
message_id: created.id,
user_id: creator.id,
client_operation_id: None,
content: "after edit".to_string(),
metadata: None,
expected_version: Some(created.version),
})
.await
.checked("test operation should succeed");
assert_eq!(edited.message.message.content, "after edit");
assert_eq!(edited.message.message.status, ChatMessageStatus::Edited);
assert_eq!(edited.message.message.version, created.version + 1);
assert!(edited.message.message.edited_at.is_some());
let retry = chat_service
.edit_message(EditChatMessage {
room_id: room.id,
message_id: created.id,
user_id: creator.id,
client_operation_id: None,
content: "after edit".to_string(),
metadata: None,
expected_version: Some(created.version),
})
.await
.checked("same edit retry should return the durable edit event");
assert_eq!(retry.event_id, edited.event_id);
assert_eq!(
retry.message.message.version,
edited.message.message.version
);
let replay = chat_service
.get_events_after(&room.id, None, 10, &ChatMessageSelection::user_default())
.await
.checked("test operation should succeed");
assert_eq!(replay.len(), 2);
assert_eq!(replay[0].event.kind, ChatEventKind::Created);
assert_eq!(replay[1].event.kind, ChatEventKind::Edited);
assert_eq!(replay[1].event.event_id, edited.event_id);
let stale = chat_service
.edit_message(EditChatMessage {
room_id: room.id,
message_id: created.id,
user_id: creator.id,
client_operation_id: None,
content: "stale edit".to_string(),
metadata: None,
expected_version: Some(created.version),
})
.await
.failed("stale version should conflict");
assert!(matches!(stale, Error::OptimisticLockConflict));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_edit_message_rejects_system_message_owned_by_actor() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let chat_repo = ChatRepository::new(pool.clone());
let creator = user_repo
.create(&make_user("system_edit_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"System Edit Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let system_event = insert_member_joined_chat_event(
&chat_repo,
room.id,
&creator,
&creator,
"system-edit-event",
)
.await;
let system_message = system_event.event.message.message;
let error = chat_service
.edit_message(EditChatMessage {
room_id: room.id,
message_id: system_message.id,
user_id: creator.id,
client_operation_id: None,
content: "tampered system event".to_string(),
metadata: None,
expected_version: Some(system_message.version),
})
.await
.failed("system messages must remain immutable");
assert!(matches!(
error,
Error::Authorization(message) if message == "System messages cannot be edited"
));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_edit_message_client_operation_id_replays_without_expected_version() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("edit_operation_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Edit Operation Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let created = chat_service
.send_message(room.id, creator.id, "before edit".to_string())
.await
.checked("test operation should succeed");
let request = EditChatMessage {
room_id: room.id,
message_id: created.id,
user_id: creator.id,
client_operation_id: Some("edit-op-1".to_string()),
content: "after edit".to_string(),
metadata: None,
expected_version: None,
};
let first = chat_service
.edit_message_outcome(request.clone())
.await
.checked("test operation should succeed");
let replay = chat_service
.edit_message_outcome(request.clone())
.await
.checked("test operation should succeed");
assert!(first.inserted);
assert!(!replay.inserted);
assert_eq!(first.event.event_id, replay.event.event_id);
assert_eq!(replay.event.message.message.content, "after edit");
let mut changed = request;
changed.content = "changed payload".to_string();
let conflict = chat_service
.edit_message_outcome(changed)
.await
.failed("same client operation id with different payload should conflict");
assert!(matches!(conflict, Error::Conflict(_)));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_message_event_soft_deletes_and_checks_expected_version() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("delete_event_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Event Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let created = chat_service
.send_message(room.id, creator.id, "delete me".to_string())
.await
.checked("test operation should succeed");
let deleted = chat_service
.delete_message_event(DeleteChatMessage {
room_id: room.id,
message_id: created.id,
user_id: creator.id,
client_operation_id: None,
reason: Some("cleanup".to_string()),
expected_version: Some(created.version),
})
.await
.checked("test operation should succeed");
assert_eq!(deleted.message.message.status, ChatMessageStatus::Deleted);
assert_eq!(deleted.message.message.version, created.version + 1);
assert_eq!(deleted.message.message.deleted_by, Some(creator.id));
assert_eq!(
deleted.message.message.delete_reason.as_deref(),
Some("cleanup")
);
assert!(deleted.message.message.deleted_at.is_some());
let retry = chat_service
.delete_message_event(DeleteChatMessage {
room_id: room.id,
message_id: created.id,
user_id: creator.id,
client_operation_id: None,
reason: Some("cleanup".to_string()),
expected_version: Some(created.version),
})
.await
.checked("same delete retry should return the durable delete event");
assert_eq!(retry.event_id, deleted.event_id);
assert_eq!(retry.kind, ChatEventKind::Deleted);
let replay = chat_service
.get_events_after(&room.id, None, 10, &ChatMessageSelection::user_default())
.await
.checked("test operation should succeed");
assert_eq!(replay.len(), 2);
assert_eq!(replay[0].event.kind, ChatEventKind::Created);
assert_eq!(replay[1].event.kind, ChatEventKind::Deleted);
assert_eq!(replay[1].event.event_id, deleted.event_id);
let stale = chat_service
.delete_message_event(DeleteChatMessage {
room_id: room.id,
message_id: created.id,
user_id: creator.id,
client_operation_id: None,
reason: None,
expected_version: Some(created.version),
})
.await
.failed("second delete should conflict");
assert!(matches!(stale, Error::Conflict(_)));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_message_client_operation_id_replays_without_expected_version() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("delete_operation_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Delete Operation Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let created = chat_service
.send_message(room.id, creator.id, "delete me".to_string())
.await
.checked("test operation should succeed");
let request = DeleteChatMessage {
room_id: room.id,
message_id: created.id,
user_id: creator.id,
client_operation_id: Some("delete-op-1".to_string()),
reason: Some("cleanup".to_string()),
expected_version: None,
};
let first = chat_service
.delete_message_event_outcome(request.clone())
.await
.checked("test operation should succeed");
let replay = chat_service
.delete_message_event_outcome(request.clone())
.await
.checked("test operation should succeed");
assert!(first.inserted);
assert!(!replay.inserted);
assert_eq!(first.event.event_id, replay.event.event_id);
assert_eq!(
replay.event.message.message.status,
ChatMessageStatus::Deleted
);
let mut changed = request;
changed.reason = Some("different cleanup".to_string());
let conflict = chat_service
.delete_message_event_outcome(changed)
.await
.failed("same client operation id with different payload should conflict");
assert!(matches!(conflict, Error::Conflict(_)));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_attachment_message_history_returns_attachment_metadata() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service_with_database_storage(&pool);
let creator = user_repo
.create(&make_user("image_history_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Attachment History Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let payload = png_test_image();
let session = create_chat_attachment_upload_session_for_payload(
&chat_service,
synctv_core::models::CreateChatAttachmentUploadSession {
room_id: room.id,
user_id: creator.id,
client_attachment_id: Some("image-1".to_string()),
filename: None,
mime_type: "image/png".to_string(),
size_bytes: i64::try_from(payload.len()).checked("test operation should succeed"),
width: Some(1),
height: Some(1),
duration_seconds: None,
bitrate_bps: None,
parts: Vec::new(),
metadata: synctv_core::models::FileMetadata {
blurhash: Some("abc".to_string()),
..Default::default()
},
},
&payload,
)
.await;
upload_chat_attachment_file(&chat_service, &session, payload).await;
let event = chat_service
.send_message_event(SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("image-msg-1".to_string()),
content: String::new(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: vec![submitted_file_reference(&session.file)],
mentions: Vec::new(),
})
.await
.checked("test operation should succeed");
let (history, _) = chat_service
.get_history_with_attachments(&room.id, None, 10, true)
.await
.checked("test operation should succeed");
assert_eq!(event.message.message.message_type, ChatMessageType::User);
assert_eq!(history.len(), 1);
assert_eq!(history[0].attachments.len(), 1);
assert_eq!(history[0].attachments[0].id, session.file.id);
assert_eq!(
history[0].attachments[0].mime_type.as_deref(),
Some("image/png")
);
assert_eq!(history[0].attachments[0].width, Some(1));
assert_eq!(history[0].attachments[0].height, Some(1));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_reused_chat_attachment_object_keeps_storage_until_last_reference_is_released() {
let (_container, pool) = create_test_pool().await;
let file_repo = FileStorageRepository::new(pool.clone());
let object_key = "database/chat/attachments/shared.webp";
let payload = b"shared-attachment";
let checksum_sha256 = hex::encode(sha2::Sha256::digest(payload));
let metadata = synctv_core::models::FileMetadata::default();
file_repo
.upsert_blob(UpsertFileBlob {
storage_backend: "database",
object_key,
mime_type: "image/webp",
size_bytes: i64::try_from(payload.len()).checked("payload length should fit"),
checksum_sha256: &checksum_sha256,
compression: FileBlobCompression::None,
data: payload.to_vec(),
metadata: &metadata,
})
.await
.checked("test operation should succeed");
file_repo
.upsert_object(UpsertFileObject {
storage_backend: "database",
object_key,
mime_type: "image/webp",
size_bytes: i64::try_from(payload.len()).checked("test operation should succeed"),
content_manifest_sha256: &checksum_sha256,
metadata: &metadata,
})
.await
.checked("test operation should succeed");
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let chat_repo = ChatRepository::new(pool.clone());
let (_chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("shared_attachment_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Shared Attachment Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let image = |id: &str| NewStoredFile {
filename: None,
id: id.to_string(),
storage_backend: "database".to_string(),
object_key: object_key.to_string(),
object_access: None,
url: Some("https://example.invalid/shared.webp".to_string()),
mime_type: Some("image/webp".to_string()),
size_bytes: Some(i64::try_from(payload.len()).checked("test operation should succeed")),
width: Some(640),
height: Some(480),
metadata: synctv_core::models::FileMetadata::default(),
};
let mut first_message = ChatMessage::new(room.id, creator.id, String::new());
first_message.client_message_id = Some("shared-attachment-msg-1".to_string());
first_message.message_type = ChatMessageType::User;
let first = checked_idempotent_insert_event(
chat_repo
.insert_message_event_idempotent(
&first_message,
&[image("shared-attachment-1")],
&[],
"shared-attachment-hash-1",
"shared-attachment-event-1",
Utc::now(),
)
.await,
);
let mut second_message = ChatMessage::new(room.id, creator.id, String::new());
second_message.client_message_id = Some("shared-attachment-msg-2".to_string());
second_message.message_type = ChatMessageType::User;
let second = checked_idempotent_insert_event(
chat_repo
.insert_message_event_idempotent(
&second_message,
&[image("shared-attachment-2")],
&[],
"shared-attachment-hash-2",
"shared-attachment-event-2",
Utc::now(),
)
.await,
);
assert_ne!(
first.event.message.message.id,
second.event.message.message.id
);
assert_eq!(
file_repo
.object_reference_count("database", object_key)
.await
.checked("test operation should succeed"),
2
);
let storage = synctv_core::service::DatabaseFileStorageService::new(
"database",
Arc::new(FileStorageRepository::new(pool.clone())),
"test-file-storage-secret",
);
storage
.delete_files(
FileStorageCleanupOrigin::ReferenceReleased,
&[FileReferenceTarget {
storage_backend: "database".to_string(),
object_key: object_key.to_string(),
reference_kind: "chat_message_attachment".to_string(),
reference_id: format!(
"{}:{}:{}:{}",
first.event.message.message.room_id.as_i64(),
first.event.message.message.id,
first.event.message.message.created_at.timestamp_micros(),
"shared-attachment-1"
),
}],
)
.await
.checked("test operation should succeed");
assert!(file_repo
.blob_exists("database", object_key)
.await
.checked("test operation should succeed"));
assert!(file_repo
.object_exists("database", object_key)
.await
.checked("test operation should succeed"));
assert_eq!(
file_repo
.object_reference_count("database", object_key)
.await
.checked("test operation should succeed"),
1
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_attachment_message_idempotency_replays_and_rejects_changed_attachments() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service_with_database_storage(&pool);
let creator = user_repo
.create(&make_user("attachment_idempotent_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Attachment Idempotent Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let payload = png_test_image();
let session = create_chat_attachment_upload_session_for_payload(
&chat_service,
synctv_core::models::CreateChatAttachmentUploadSession {
room_id: room.id,
user_id: creator.id,
client_attachment_id: Some("idempotent-attachment-1".to_string()),
filename: None,
mime_type: "image/png".to_string(),
size_bytes: i64::try_from(payload.len()).checked("test operation should succeed"),
width: Some(1),
height: Some(1),
duration_seconds: None,
bitrate_bps: None,
parts: Vec::new(),
metadata: synctv_core::models::FileMetadata {
blurhash: Some("abc".to_string()),
..Default::default()
},
},
&payload,
)
.await;
upload_chat_attachment_file(&chat_service, &session, payload).await;
let mut request = SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("image-idempotent-msg".to_string()),
content: String::new(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: vec![submitted_file_reference(&session.file)],
mentions: Vec::new(),
};
let first = chat_service
.send_message_event_outcome(request.clone())
.await
.checked("test operation should succeed");
let replay = chat_service
.send_message_event_outcome(request.clone())
.await
.checked("test operation should succeed");
assert!(first.inserted);
assert!(!replay.inserted);
assert_eq!(first.event.event_id, replay.event.event_id);
assert_eq!(replay.event.message.attachments.len(), 1);
assert_eq!(
replay.event.message.attachments[0].object_key,
session.file.object_key
);
let mut changed_payload = png_test_image();
changed_payload.extend_from_slice(b"changed");
let changed_session = create_chat_attachment_upload_session_for_payload(
&chat_service,
synctv_core::models::CreateChatAttachmentUploadSession {
room_id: room.id,
user_id: creator.id,
client_attachment_id: Some("idempotent-attachment-2".to_string()),
filename: None,
mime_type: "image/png".to_string(),
size_bytes: i64::try_from(changed_payload.len())
.checked("test operation should succeed"),
width: Some(1),
height: Some(1),
duration_seconds: None,
bitrate_bps: None,
parts: Vec::new(),
metadata: synctv_core::models::FileMetadata {
blurhash: Some("abc".to_string()),
..Default::default()
},
},
&changed_payload,
)
.await;
upload_chat_attachment_file(&chat_service, &changed_session, changed_payload).await;
request.attachments[0] = submitted_file_reference(&changed_session.file);
let changed = chat_service
.send_message_event_outcome(request)
.await
.failed("same client_message_id with changed attachment should conflict");
assert!(matches!(changed, Error::Conflict(_)));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_chat_message_attachments_require_matching_room_id() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("image_room_fk_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Attachment Room FK".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let (other_room, _) = room_service
.create_room(
"Other Attachment Room FK".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let event = chat_service
.send_message_event(SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("attachment-room-fk-message".to_string()),
content: "message".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
})
.await
.checked("test operation should succeed");
let result = sqlx::query!(
r"
INSERT INTO chat_message_attachments (
id, kind, room_id, message_id, message_created_at, filename, storage_backend,
object_key, url, mime_type, size_bytes, width, height, metadata
)
VALUES ($1, 2, $2, $3, $4, NULL, $5, $6, NULL, $7, $8, $9, $10, $11)
",
"wrong-room-attachment",
other_room.id.as_i64(),
event.message.message.id,
event.message.message.created_at,
"local",
"rooms/wrong/image.webp",
"image/webp",
42_i64,
640_i32,
480_i32,
serde_json::Value::Object(Default::default())
)
.execute(&pool)
.await;
assert!(result.is_err());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_deleted_attachment_message_history_hides_attachment_metadata() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service_with_database_storage(&pool);
let creator = user_repo
.create(&make_user("deleted_image_history_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Deleted Attachment History Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let payload = png_test_image();
let session = create_chat_attachment_upload_session_for_payload(
&chat_service,
synctv_core::models::CreateChatAttachmentUploadSession {
room_id: room.id,
user_id: creator.id,
client_attachment_id: Some("deleted-attachment-1".to_string()),
filename: None,
mime_type: "image/png".to_string(),
size_bytes: i64::try_from(payload.len()).checked("test operation should succeed"),
width: Some(1),
height: Some(1),
duration_seconds: None,
bitrate_bps: None,
parts: Vec::new(),
metadata: synctv_core::models::FileMetadata {
blurhash: Some("abc".to_string()),
..Default::default()
},
},
&payload,
)
.await;
upload_chat_attachment_file(&chat_service, &session, payload).await;
let event = chat_service
.send_message_event(SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("deleted-attachment-msg-1".to_string()),
content: String::new(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: vec![submitted_file_reference(&session.file)],
mentions: Vec::new(),
})
.await
.checked("test operation should succeed");
chat_service
.delete_message_event(DeleteChatMessage {
room_id: room.id,
message_id: event.message.message.id,
user_id: creator.id,
client_operation_id: None,
reason: Some("cleanup".to_string()),
expected_version: Some(event.message.message.version),
})
.await
.checked("test operation should succeed");
let (history, _) = chat_service
.get_history_with_attachments(&room.id, None, 10, true)
.await
.checked("test operation should succeed");
assert_eq!(history.len(), 1);
assert_eq!(history[0].message.status, ChatMessageStatus::Deleted);
assert!(history[0].message.content.is_empty());
assert!(history[0].attachments.is_empty());
let (visible_history, _) = chat_service
.get_history_with_attachments(&room.id, None, 10, false)
.await
.checked("test operation should succeed");
assert!(visible_history.is_empty());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_send_message_rejects_missing_or_deleted_reply_target() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("reply_target_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Reply Target Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let missing = chat_service
.send_message_event(SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("missing-reply".to_string()),
content: "reply".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: Some(9_999_999),
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
})
.await
.failed("missing reply target should be rejected");
assert!(matches!(missing, Error::NotFound(_)));
let target = chat_service
.send_message_event(SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("reply-target".to_string()),
content: "target".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
})
.await
.checked("test operation should succeed");
let reply = chat_service
.send_message_event(SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("valid-reply".to_string()),
content: "valid reply".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: Some(target.message.message.id),
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
})
.await
.checked("test operation should succeed");
assert_eq!(
reply.message.message.reply_to_message_id,
Some(target.message.message.id)
);
assert_eq!(
reply.message.message.reply_to_message_created_at,
Some(target.message.message.created_at)
);
chat_service
.delete_message_event(DeleteChatMessage {
room_id: room.id,
message_id: target.message.message.id,
user_id: creator.id,
client_operation_id: None,
reason: None,
expected_version: Some(target.message.message.version),
})
.await
.checked("test operation should succeed");
let deleted = chat_service
.send_message_event(SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("deleted-reply".to_string()),
content: "reply".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: Some(target.message.message.id),
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
})
.await
.failed("deleted reply target should be rejected");
assert!(matches!(deleted, Error::Conflict(_)));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_idempotent_reply_send_replays_after_reply_target_is_deleted() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("reply_replay_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Reply Replay Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let target = chat_service
.send_message_event(SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("reply-replay-target".to_string()),
content: "target".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: None,
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
})
.await
.checked("test operation should succeed");
let reply_request = SendChatMessage {
room_id: room.id,
user_id: creator.id,
client_message_id: Some("reply-replay".to_string()),
content: "reply".to_string(),
message_type: ChatMessageType::User,
reply_to_message_id: Some(target.message.message.id),
metadata: None,
attachments: Vec::new(),
mentions: Vec::new(),
};
let first = chat_service
.send_message_event_outcome(reply_request.clone())
.await
.checked("test operation should succeed");
assert!(first.inserted);
chat_service
.delete_message_event(DeleteChatMessage {
room_id: room.id,
message_id: target.message.message.id,
user_id: creator.id,
client_operation_id: None,
reason: None,
expected_version: Some(target.message.message.version),
})
.await
.checked("test operation should succeed");
let replay = chat_service
.send_message_event_outcome(reply_request)
.await
.checked("test operation should succeed");
assert!(!replay.inserted);
assert_eq!(replay.event.event_id, first.event.event_id);
assert_eq!(
replay.event.message.message.id,
first.event.message.message.id
);
assert_eq!(
replay.event.message.message.reply_to_message_created_at,
Some(target.message.message.created_at)
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_send_message_html_xss_stripped() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("xss_strip_creator"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"XSS Strip Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
let msg = chat_service
.send_message(
room.id,
creator.id,
"<script>alert('xss')</script>Hello safe world".to_string(),
)
.await
.checked("Message with HTML should be filtered, not rejected");
// The content filter strips HTML tags, so <script> should be removed
assert!(
!msg.content.contains("<script>"),
"HTML script tags should be stripped from message content: got '{}'",
msg.content
);
assert!(
msg.content.contains("Hello safe world"),
"Safe text content should be preserved: got '{}'",
msg.content
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_message_with_deleted_user_requires_permission() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let creator = user_repo
.create(&make_user("del_msg_null_creator"))
.await
.checked("test operation should succeed");
let member = user_repo
.create(&make_user("del_msg_null_member"))
.await
.checked("test operation should succeed");
username_cache
.set(&creator.id, &creator.username)
.await
.checked("test operation should succeed");
username_cache
.set(&member.id, &member.username)
.await
.checked("test operation should succeed");
let (room, _) = room_service
.create_room(
"Del Null User Room".to_string(),
String::new(),
creator.id,
None,
None,
)
.await
.checked("test operation should succeed");
room_service
.join_room(room.id, member.id, None)
.await
.checked("test operation should succeed");
// Creator sends a message
let msg = chat_service
.send_message(room.id, creator.id, "Orphaned message".to_string())
.await
.checked("test operation should succeed");
// Simulate user deletion: SET user_id to NULL
sqlx::query!(
"UPDATE chat_messages SET user_id = NULL WHERE id = $1 AND created_at = $2",
msg.id,
msg.created_at
)
.execute(&pool)
.await
.checked("test operation should succeed");
// Member (without DELETE_CHAT_MESSAGES permission) tries to delete orphaned message
// Since user_id is NULL, they are not the sender, so they need DELETE_CHAT_MESSAGES permission
let result = chat_service
.delete_message(&room.id, msg.id, &member.id)
.await;
assert!(
result.is_err(),
"Non-owner should be denied deletion of orphaned message without DELETE_CHAT_MESSAGES permission"
);
match result.failed("operation should fail") {
Error::Authorization(_) => {}
other => std::panic::panic_any(format!("expected Authorization error, got: {other:?}")),
}
// Room creator (has all permissions) should be able to delete
let result = chat_service
.delete_message(&room.id, msg.id, &creator.id)
.await;
assert!(
result.is_ok(),
"Room creator (with DELETE_CHAT_MESSAGES) should be able to delete orphaned message"
);
}