use std::sync::Arc;
use modkit_security::AccessScope;
use uuid::Uuid;
use crate::domain::error::DomainError;
use crate::domain::models::{NewChat, ReactionKind};
use crate::domain::repos::{
InsertAssistantMessageParams, InsertUserMessageParams, MessageRepository as MessageRepoTrait,
};
use crate::domain::service::test_helpers::{
MockThreadSummaryRepo, NoopOutboxEnqueuer, inmem_db, mock_db_provider, mock_enforcer,
mock_model_resolver, mock_tenant_only_enforcer, mock_thread_summary_repo, test_security_ctx,
test_security_ctx_with_id,
};
use crate::infra::db::repo::attachment_repo::AttachmentRepository as OrmAttachmentRepository;
use crate::infra::db::repo::chat_repo::ChatRepository as OrmChatRepository;
use crate::infra::db::repo::message_repo::MessageRepository as OrmMessageRepository;
use crate::infra::db::repo::reaction_repo::ReactionRepository as OrmReactionRepository;
use super::ReactionService;
use crate::domain::service::ChatService;
fn limit_cfg() -> modkit_db::odata::LimitCfg {
modkit_db::odata::LimitCfg {
default: 20,
max: 100,
}
}
fn build_chat_service(
db_provider: Arc<crate::domain::service::DbProvider>,
chat_repo: Arc<OrmChatRepository>,
) -> ChatService<OrmChatRepository, OrmAttachmentRepository, MockThreadSummaryRepo> {
ChatService::new(
db_provider,
chat_repo,
Arc::new(OrmAttachmentRepository),
mock_thread_summary_repo(),
Arc::new(NoopOutboxEnqueuer),
mock_enforcer(),
mock_model_resolver(),
)
}
fn build_reaction_service(
db_provider: Arc<crate::domain::service::DbProvider>,
chat_repo: Arc<OrmChatRepository>,
) -> ReactionService<OrmReactionRepository, OrmMessageRepository, OrmChatRepository> {
let reaction_repo = Arc::new(OrmReactionRepository);
let message_repo = Arc::new(OrmMessageRepository::new(limit_cfg()));
ReactionService::new(
db_provider,
reaction_repo,
message_repo,
chat_repo,
mock_enforcer(),
)
}
fn build_reaction_service_tenant_only_authz(
db_provider: Arc<crate::domain::service::DbProvider>,
chat_repo: Arc<OrmChatRepository>,
) -> ReactionService<OrmReactionRepository, OrmMessageRepository, OrmChatRepository> {
let reaction_repo = Arc::new(OrmReactionRepository);
let message_repo = Arc::new(OrmMessageRepository::new(limit_cfg()));
ReactionService::new(
db_provider,
reaction_repo,
message_repo,
chat_repo,
mock_tenant_only_enforcer(),
)
}
async fn setup_chat_with_messages(
db_provider: &Arc<crate::domain::service::DbProvider>,
chat_repo: &Arc<OrmChatRepository>,
ctx: &modkit_security::SecurityContext,
tenant_id: Uuid,
) -> (Uuid, Uuid, Uuid) {
let chat_svc = build_chat_service(Arc::clone(db_provider), Arc::clone(chat_repo));
let chat = chat_svc
.create_chat(
ctx,
NewChat {
model: None,
title: Some("Test chat".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat failed");
let scope = AccessScope::for_tenant(tenant_id);
let conn = db_provider.conn().expect("conn failed");
let message_repo = OrmMessageRepository::new(limit_cfg());
let request_id = Uuid::new_v4();
let user_msg_id = Uuid::now_v7();
message_repo
.insert_user_message(
&conn,
&scope,
InsertUserMessageParams {
id: user_msg_id,
tenant_id,
chat_id: chat.id,
request_id,
content: "Hello".to_owned(),
},
)
.await
.expect("insert_user_message failed");
let assistant_msg_id = Uuid::now_v7();
message_repo
.insert_assistant_message(
&conn,
&scope,
InsertAssistantMessageParams {
id: assistant_msg_id,
tenant_id,
chat_id: chat.id,
request_id,
content: "Hi there!".to_owned(),
input_tokens: Some(10),
output_tokens: Some(20),
cache_read_input_tokens: None,
cache_write_input_tokens: None,
reasoning_tokens: None,
model: Some("gpt-5.2".to_owned()),
provider_response_id: None,
},
)
.await
.expect("insert_assistant_message failed");
(chat.id, user_msg_id, assistant_msg_id)
}
#[tokio::test]
async fn set_reaction_on_assistant_message() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let (chat_id, _user_msg_id, assistant_msg_id) =
setup_chat_with_messages(&db_provider, &chat_repo, &ctx, tenant_id).await;
let reaction_svc = build_reaction_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let reaction = reaction_svc
.set_reaction(&ctx, chat_id, assistant_msg_id, "like")
.await
.expect("set_reaction failed");
assert_eq!(reaction.message_id, assistant_msg_id);
assert_eq!(reaction.kind, ReactionKind::Like);
}
#[tokio::test]
async fn set_reaction_on_user_message_rejected() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let (chat_id, user_msg_id, _assistant_msg_id) =
setup_chat_with_messages(&db_provider, &chat_repo, &ctx, tenant_id).await;
let reaction_svc = build_reaction_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let result = reaction_svc
.set_reaction(&ctx, chat_id, user_msg_id, "like")
.await;
assert!(result.is_err(), "Should reject reaction on user message");
assert!(
matches!(
result.unwrap_err(),
DomainError::InvalidReactionTarget { .. }
),
"Expected InvalidReactionTarget"
);
}
#[tokio::test]
async fn set_reaction_upsert_replaces() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let (chat_id, _user_msg_id, assistant_msg_id) =
setup_chat_with_messages(&db_provider, &chat_repo, &ctx, tenant_id).await;
let reaction_svc = build_reaction_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let r1 = reaction_svc
.set_reaction(&ctx, chat_id, assistant_msg_id, "like")
.await
.expect("set_reaction like failed");
assert_eq!(r1.kind, ReactionKind::Like);
let r2 = reaction_svc
.set_reaction(&ctx, chat_id, assistant_msg_id, "dislike")
.await
.expect("set_reaction dislike failed");
assert_eq!(r2.kind, ReactionKind::Dislike);
}
#[tokio::test]
async fn set_reaction_invalid_value_rejected() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let (chat_id, _user_msg_id, assistant_msg_id) =
setup_chat_with_messages(&db_provider, &chat_repo, &ctx, tenant_id).await;
let reaction_svc = build_reaction_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let result = reaction_svc
.set_reaction(&ctx, chat_id, assistant_msg_id, "love")
.await;
assert!(result.is_err(), "Should reject invalid reaction value");
assert!(
matches!(result.unwrap_err(), DomainError::Validation { .. }),
"Expected Validation error"
);
}
#[tokio::test]
async fn delete_reaction_happy_path() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let (chat_id, _user_msg_id, assistant_msg_id) =
setup_chat_with_messages(&db_provider, &chat_repo, &ctx, tenant_id).await;
let reaction_svc = build_reaction_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
reaction_svc
.set_reaction(&ctx, chat_id, assistant_msg_id, "like")
.await
.expect("set_reaction failed");
reaction_svc
.delete_reaction(&ctx, chat_id, assistant_msg_id)
.await
.expect("delete_reaction failed");
}
#[tokio::test]
async fn delete_reaction_idempotent() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let (chat_id, _user_msg_id, assistant_msg_id) =
setup_chat_with_messages(&db_provider, &chat_repo, &ctx, tenant_id).await;
let reaction_svc = build_reaction_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
reaction_svc
.delete_reaction(&ctx, chat_id, assistant_msg_id)
.await
.expect("delete_reaction should be idempotent");
}
#[tokio::test]
async fn set_reaction_chat_not_found() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let reaction_svc = build_reaction_service(db_provider, chat_repo);
let ctx = test_security_ctx(Uuid::new_v4());
let random_chat_id = Uuid::new_v4();
let random_msg_id = Uuid::new_v4();
let result = reaction_svc
.set_reaction(&ctx, random_chat_id, random_msg_id, "like")
.await;
assert!(result.is_err(), "Expected error for non-existent chat");
assert!(
matches!(result.unwrap_err(), DomainError::ChatNotFound { .. }),
"Expected ChatNotFound"
);
}
#[tokio::test]
async fn set_reaction_message_not_found() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let chat = chat_svc
.create_chat(
&ctx,
NewChat {
model: None,
title: Some("Test chat".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat failed");
let reaction_svc = build_reaction_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let random_msg_id = Uuid::new_v4();
let result = reaction_svc
.set_reaction(&ctx, chat.id, random_msg_id, "like")
.await;
assert!(result.is_err(), "Expected error for non-existent message");
assert!(
matches!(result.unwrap_err(), DomainError::MessageNotFound { .. }),
"Expected MessageNotFound"
);
}
#[tokio::test]
async fn set_reaction_cross_tenant_rejected() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let tenant_a = Uuid::new_v4();
let tenant_b = Uuid::new_v4();
let ctx_a = test_security_ctx(tenant_a);
let ctx_b = test_security_ctx(tenant_b);
let (chat_id, _user_msg_id, assistant_msg_id) =
setup_chat_with_messages(&db_provider, &chat_repo, &ctx_a, tenant_a).await;
let reaction_svc = build_reaction_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let result = reaction_svc
.set_reaction(&ctx_b, chat_id, assistant_msg_id, "like")
.await;
assert!(result.is_err(), "Cross-tenant reaction must fail");
assert!(
matches!(result.unwrap_err(), DomainError::ChatNotFound { .. }),
"Expected ChatNotFound for cross-tenant access"
);
}
#[tokio::test]
async fn set_reaction_tenant_only_authz_cross_owner_not_found() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let tenant_id = Uuid::new_v4();
let user_a = Uuid::new_v4();
let user_b = Uuid::new_v4();
let ctx_a = test_security_ctx_with_id(tenant_id, user_a);
let ctx_b = test_security_ctx_with_id(tenant_id, user_b);
let (chat_id, _user_msg_id, assistant_msg_id) =
setup_chat_with_messages(&db_provider, &chat_repo, &ctx_a, tenant_id).await;
let reaction_svc =
build_reaction_service_tenant_only_authz(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let result = reaction_svc
.set_reaction(&ctx_b, chat_id, assistant_msg_id, "like")
.await;
assert!(
result.is_err(),
"Cross-owner reaction must fail with tenant-only authz"
);
assert!(
matches!(result.unwrap_err(), DomainError::ChatNotFound { .. }),
"Expected ChatNotFound for cross-owner access with tenant-only authz"
);
}