use std::sync::Arc;
use modkit_db::secure::secure_insert;
use modkit_odata::ODataQuery;
use modkit_security::AccessScope;
use sea_orm::Set;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::domain::error::DomainError;
use crate::domain::models::NewChat;
use crate::domain::repos::{
InsertAssistantMessageParams, InsertUserMessageParams, MessageRepository as MessageRepoTrait,
ReactionRepository as ReactionRepoTrait, UpsertReactionParams,
};
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::entity::attachment::{
ActiveModel as AttAm, AttachmentKind, AttachmentStatus, Entity as AttEntity,
};
use crate::infra::db::entity::message_attachment::{ActiveModel as MaAm, Entity as MaEntity};
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::MessageService;
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_message_service(
db_provider: Arc<crate::domain::service::DbProvider>,
chat_repo: Arc<OrmChatRepository>,
) -> MessageService<OrmMessageRepository, OrmChatRepository, OrmReactionRepository> {
let message_repo = Arc::new(OrmMessageRepository::new(limit_cfg()));
let reaction_repo = Arc::new(OrmReactionRepository);
MessageService::new(
db_provider,
message_repo,
chat_repo,
reaction_repo,
mock_enforcer(),
)
}
fn build_message_service_tenant_only_authz(
db_provider: Arc<crate::domain::service::DbProvider>,
chat_repo: Arc<OrmChatRepository>,
) -> MessageService<OrmMessageRepository, OrmChatRepository, OrmReactionRepository> {
let message_repo = Arc::new(OrmMessageRepository::new(limit_cfg()));
let reaction_repo = Arc::new(OrmReactionRepository);
MessageService::new(
db_provider,
message_repo,
chat_repo,
reaction_repo,
mock_tenant_only_enforcer(),
)
}
fn build_chat_service_tenant_only_authz(
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_tenant_only_enforcer(),
mock_model_resolver(),
)
}
#[tokio::test]
async fn list_messages_empty_chat() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let msg_svc = build_message_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let chat = chat_svc
.create_chat(
&ctx,
NewChat {
model: None,
title: Some("Empty chat".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat failed");
let page = msg_svc
.list_messages(&ctx, chat.id, &ODataQuery::default())
.await
.expect("list_messages failed");
assert!(page.items.is_empty(), "Expected no messages in new chat");
}
#[tokio::test]
async fn list_messages_returns_messages_chronologically() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let msg_svc = build_message_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let chat = chat_svc
.create_chat(
&ctx,
NewChat {
model: None,
title: Some("With messages".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();
message_repo
.insert_user_message(
&conn,
&scope,
InsertUserMessageParams {
id: Uuid::now_v7(),
tenant_id,
chat_id: chat.id,
request_id,
content: "Hello".to_owned(),
},
)
.await
.expect("insert_user_message failed");
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
message_repo
.insert_assistant_message(
&conn,
&scope,
InsertAssistantMessageParams {
id: Uuid::now_v7(),
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");
let page = msg_svc
.list_messages(&ctx, chat.id, &ODataQuery::default())
.await
.expect("list_messages failed");
assert_eq!(page.items.len(), 2, "Expected 2 messages");
assert_eq!(page.items[0].role, "user", "First message should be user");
assert_eq!(
page.items[1].role, "assistant",
"Second message should be assistant"
);
assert!(
page.items[0].created_at <= page.items[1].created_at,
"Messages should be in chronological order"
);
}
#[tokio::test]
async fn list_messages_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 msg_svc = build_message_service(db_provider, chat_repo);
let ctx = test_security_ctx(Uuid::new_v4());
let random_chat_id = Uuid::new_v4();
let result = msg_svc
.list_messages(&ctx, random_chat_id, &ODataQuery::default())
.await;
assert!(result.is_err(), "Expected error for non-existent chat");
assert!(
matches!(result.unwrap_err(), DomainError::ChatNotFound { .. }),
"Expected ChatNotFound"
);
}
#[tokio::test]
async fn list_messages_cross_tenant_returns_not_found() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let msg_svc = build_message_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
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 = chat_svc
.create_chat(
&ctx_a,
NewChat {
model: None,
title: Some("Tenant A chat".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat failed");
let result = msg_svc
.list_messages(&ctx_b, chat.id, &ODataQuery::default())
.await;
assert!(result.is_err(), "Cross-tenant list must fail");
assert!(
matches!(result.unwrap_err(), DomainError::ChatNotFound { .. }),
"Expected ChatNotFound for cross-tenant access"
);
}
async fn insert_message_pairs(
db_provider: &Arc<crate::domain::service::DbProvider>,
tenant_id: Uuid,
chat_id: Uuid,
count: usize,
) {
let scope = AccessScope::for_tenant(tenant_id);
let conn = db_provider.conn().expect("conn failed");
let message_repo = OrmMessageRepository::new(limit_cfg());
for _ in 0..count {
let request_id = Uuid::new_v4();
message_repo
.insert_user_message(
&conn,
&scope,
InsertUserMessageParams {
id: Uuid::now_v7(),
tenant_id,
chat_id,
request_id,
content: "Hello".to_owned(),
},
)
.await
.expect("insert_user_message failed");
message_repo
.insert_assistant_message(
&conn,
&scope,
InsertAssistantMessageParams {
id: Uuid::now_v7(),
tenant_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");
}
}
#[tokio::test]
async fn list_messages_pagination_forward_cursor() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let msg_svc = build_message_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let chat = chat_svc
.create_chat(
&ctx,
NewChat {
model: None,
title: Some("Pagination chat".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat failed");
insert_message_pairs(&db_provider, tenant_id, chat.id, 5).await;
let query = ODataQuery::new().with_limit(3);
let page1 = msg_svc
.list_messages(&ctx, chat.id, &query)
.await
.expect("list_messages page 1 failed");
assert_eq!(page1.items.len(), 3, "Page 1 should have 3 items");
assert!(
page1.page_info.next_cursor.is_some(),
"Page 1 must have next_cursor (7 more messages remain)"
);
assert!(
page1.page_info.prev_cursor.is_none(),
"Page 1 must not have prev_cursor (first page)"
);
let cursor = modkit_odata::CursorV1::decode(page1.page_info.next_cursor.as_ref().unwrap())
.expect("decode cursor failed");
let query2 = ODataQuery::new().with_limit(3).with_cursor(cursor);
let page2 = msg_svc
.list_messages(&ctx, chat.id, &query2)
.await
.expect("list_messages page 2 failed");
assert_eq!(page2.items.len(), 3, "Page 2 should have 3 items");
assert!(
page2.page_info.next_cursor.is_some(),
"Page 2 must have next_cursor (4 more messages remain)"
);
let mut all_ids: Vec<Uuid> = page1
.items
.iter()
.chain(page2.items.iter())
.map(|m| m.id)
.collect();
let mut current_page = page2;
while let Some(ref next) = current_page.page_info.next_cursor {
let cursor = modkit_odata::CursorV1::decode(next).expect("decode cursor failed");
let q = ODataQuery::new().with_limit(3).with_cursor(cursor);
current_page = msg_svc
.list_messages(&ctx, chat.id, &q)
.await
.expect("list_messages next page failed");
all_ids.extend(current_page.items.iter().map(|m| m.id));
}
assert_eq!(
all_ids.len(),
10,
"Total messages across all pages should be 10"
);
let unique_count = {
let mut sorted = all_ids.clone();
sorted.sort();
sorted.dedup();
sorted.len()
};
assert_eq!(unique_count, 10, "All message IDs must be unique");
}
#[tokio::test]
async fn list_messages_pagination_no_cursor_when_all_fit() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let msg_svc = build_message_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let chat = chat_svc
.create_chat(
&ctx,
NewChat {
model: None,
title: Some("Small chat".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat failed");
insert_message_pairs(&db_provider, tenant_id, chat.id, 2).await;
let query = ODataQuery::new().with_limit(20);
let page = msg_svc
.list_messages(&ctx, chat.id, &query)
.await
.expect("list_messages failed");
assert_eq!(page.items.len(), 4);
assert!(
page.page_info.next_cursor.is_none(),
"No next_cursor when all messages fit in a single page"
);
assert!(
page.page_info.prev_cursor.is_none(),
"No prev_cursor on the first (and only) page"
);
}
#[tokio::test]
async fn list_messages_pagination_backward_cursor() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let msg_svc = build_message_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let chat = chat_svc
.create_chat(
&ctx,
NewChat {
model: None,
title: Some("Backward pagination chat".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat failed");
insert_message_pairs(&db_provider, tenant_id, chat.id, 5).await;
let query = ODataQuery::new().with_limit(3);
let page1 = msg_svc
.list_messages(&ctx, chat.id, &query)
.await
.expect("list_messages page 1 failed");
assert_eq!(page1.items.len(), 3);
let cursor = modkit_odata::CursorV1::decode(page1.page_info.next_cursor.as_ref().unwrap())
.expect("decode cursor failed");
let query2 = ODataQuery::new().with_limit(3).with_cursor(cursor);
let page2 = msg_svc
.list_messages(&ctx, chat.id, &query2)
.await
.expect("list_messages page 2 failed");
assert_eq!(page2.items.len(), 3);
assert!(
page2.page_info.prev_cursor.is_some(),
"Page 2 must have prev_cursor"
);
let prev = modkit_odata::CursorV1::decode(page2.page_info.prev_cursor.as_ref().unwrap())
.expect("decode prev cursor failed");
let query_back = ODataQuery::new().with_limit(3).with_cursor(prev);
let page_back = msg_svc
.list_messages(&ctx, chat.id, &query_back)
.await
.expect("list_messages backward failed");
assert_eq!(
page_back.items.len(),
page1.items.len(),
"Backward page should have same count as page 1"
);
let back_ids: Vec<Uuid> = page_back.items.iter().map(|m| m.id).collect();
let page1_ids: Vec<Uuid> = page1.items.iter().map(|m| m.id).collect();
assert_eq!(
back_ids, page1_ids,
"Backward navigation must return to page 1 items"
);
}
async fn insert_attachment(
db_provider: &Arc<crate::domain::service::DbProvider>,
tenant_id: Uuid,
chat_id: Uuid,
kind: AttachmentKind,
filename: &str,
status: AttachmentStatus,
img_thumbnail: Option<(Vec<u8>, i32, i32)>,
) -> Uuid {
let now = OffsetDateTime::now_utc();
let att_id = Uuid::now_v7();
let (thumb_bytes, thumb_w, thumb_h) = match img_thumbnail {
Some((bytes, w, h)) => (Some(bytes), Some(w), Some(h)),
None => (None, None, None),
};
let am = AttAm {
id: Set(att_id),
tenant_id: Set(tenant_id),
chat_id: Set(chat_id),
uploaded_by_user_id: Set(Uuid::new_v4()),
filename: Set(filename.to_owned()),
content_type: Set("application/octet-stream".to_owned()),
size_bytes: Set(1024),
storage_backend: Set("azure".to_owned()),
provider_file_id: Set(None),
status: Set(status),
error_code: Set(None),
attachment_kind: Set(kind),
for_file_search: Set(true),
for_code_interpreter: Set(false),
doc_summary: Set(None),
img_thumbnail: Set(thumb_bytes),
img_thumbnail_width: Set(thumb_w),
img_thumbnail_height: Set(thumb_h),
summary_model: Set(None),
summary_updated_at: Set(None),
cleanup_status: Set(None),
cleanup_attempts: Set(0),
last_cleanup_error: Set(None),
cleanup_updated_at: Set(None),
created_at: Set(now),
updated_at: Set(now),
deleted_at: Set(None),
};
let conn = db_provider.conn().expect("conn");
let scope = AccessScope::allow_all();
secure_insert::<AttEntity>(am, &scope, &conn)
.await
.expect("insert attachment");
att_id
}
async fn link_message_attachment(
db_provider: &Arc<crate::domain::service::DbProvider>,
tenant_id: Uuid,
chat_id: Uuid,
message_id: Uuid,
attachment_id: Uuid,
) {
let now = OffsetDateTime::now_utc();
let am = MaAm {
tenant_id: Set(tenant_id),
chat_id: Set(chat_id),
message_id: Set(message_id),
attachment_id: Set(attachment_id),
created_at: Set(now),
};
let conn = db_provider.conn().expect("conn");
let scope = AccessScope::allow_all();
secure_insert::<MaEntity>(am, &scope, &conn)
.await
.expect("link message attachment");
}
#[tokio::test]
async fn list_messages_returns_attachments() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let msg_svc = build_message_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let chat = chat_svc
.create_chat(
&ctx,
NewChat {
model: None,
title: Some("Attachments test".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat");
let scope = AccessScope::for_tenant(tenant_id);
let conn = db_provider.conn().expect("conn");
let message_repo = OrmMessageRepository::new(limit_cfg());
let request_id = Uuid::new_v4();
let msg_id = Uuid::now_v7();
message_repo
.insert_user_message(
&conn,
&scope,
InsertUserMessageParams {
id: msg_id,
tenant_id,
chat_id: chat.id,
request_id,
content: "See attached".to_owned(),
},
)
.await
.expect("insert_user_message");
let att_a = insert_attachment(
&db_provider,
tenant_id,
chat.id,
AttachmentKind::Document,
"report.pdf",
AttachmentStatus::Ready,
None,
)
.await;
let att_b = insert_attachment(
&db_provider,
tenant_id,
chat.id,
AttachmentKind::Image,
"photo.webp",
AttachmentStatus::Ready,
Some((vec![0xFF, 0xD8], 120, 80)),
)
.await;
link_message_attachment(&db_provider, tenant_id, chat.id, msg_id, att_a).await;
link_message_attachment(&db_provider, tenant_id, chat.id, msg_id, att_b).await;
let page = msg_svc
.list_messages(&ctx, chat.id, &ODataQuery::default())
.await
.expect("list_messages");
assert_eq!(page.items.len(), 1);
let msg = &page.items[0];
assert_eq!(
msg.attachments.len(),
2,
"message should have 2 attachments"
);
let att_ids: Vec<Uuid> = msg.attachments.iter().map(|a| a.attachment_id).collect();
assert!(att_ids.contains(&att_a), "must contain att_a");
assert!(att_ids.contains(&att_b), "must contain att_b");
let img_att = msg
.attachments
.iter()
.find(|a| a.attachment_id == att_b)
.expect("image attachment");
assert_eq!(img_att.kind, "image");
assert_eq!(img_att.filename, "photo.webp");
let thumb = img_att.img_thumbnail.as_ref().expect("thumbnail present");
assert_eq!(thumb.width, 120);
assert_eq!(thumb.height, 80);
let doc_att = msg
.attachments
.iter()
.find(|a| a.attachment_id == att_a)
.expect("document attachment");
assert_eq!(doc_att.kind, "document");
assert!(doc_att.img_thumbnail.is_none());
}
#[tokio::test]
async fn list_messages_no_attachments_returns_empty_vec() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let msg_svc = build_message_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let chat = chat_svc
.create_chat(
&ctx,
NewChat {
model: None,
title: Some("No attachments".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat");
let scope = AccessScope::for_tenant(tenant_id);
let conn = db_provider.conn().expect("conn");
let message_repo = OrmMessageRepository::new(limit_cfg());
message_repo
.insert_user_message(
&conn,
&scope,
InsertUserMessageParams {
id: Uuid::now_v7(),
tenant_id,
chat_id: chat.id,
request_id: Uuid::new_v4(),
content: "Plain message".to_owned(),
},
)
.await
.expect("insert_user_message");
let page = msg_svc
.list_messages(&ctx, chat.id, &ODataQuery::default())
.await
.expect("list_messages");
assert_eq!(page.items.len(), 1);
assert!(
page.items[0].attachments.is_empty(),
"message without links must have empty attachments"
);
}
#[tokio::test]
async fn list_messages_mixed_messages_with_and_without_attachments() {
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let msg_svc = build_message_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let tenant_id = Uuid::new_v4();
let ctx = test_security_ctx(tenant_id);
let chat = chat_svc
.create_chat(
&ctx,
NewChat {
model: None,
title: Some("Mixed".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat");
let scope = AccessScope::for_tenant(tenant_id);
let conn = db_provider.conn().expect("conn");
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: "With file".to_owned(),
},
)
.await
.expect("insert_user_message");
let att_id = insert_attachment(
&db_provider,
tenant_id,
chat.id,
AttachmentKind::Document,
"notes.txt",
AttachmentStatus::Ready,
None,
)
.await;
link_message_attachment(&db_provider, tenant_id, chat.id, user_msg_id, att_id).await;
let asst_msg_id = Uuid::now_v7();
message_repo
.insert_assistant_message(
&conn,
&scope,
InsertAssistantMessageParams {
id: asst_msg_id,
tenant_id,
chat_id: chat.id,
request_id,
content: "Got it".to_owned(),
input_tokens: None,
output_tokens: None,
cache_read_input_tokens: None,
cache_write_input_tokens: None,
reasoning_tokens: None,
model: None,
provider_response_id: None,
},
)
.await
.expect("insert_assistant_message");
let page = msg_svc
.list_messages(&ctx, chat.id, &ODataQuery::default())
.await
.expect("list_messages");
assert_eq!(page.items.len(), 2);
let user_msg = page
.items
.iter()
.find(|m| m.id == user_msg_id)
.expect("user msg");
assert_eq!(user_msg.attachments.len(), 1);
assert_eq!(user_msg.attachments[0].attachment_id, att_id);
let asst_msg = page
.items
.iter()
.find(|m| m.id == asst_msg_id)
.expect("asst msg");
assert!(
asst_msg.attachments.is_empty(),
"assistant message must have no attachments"
);
}
#[tokio::test]
async fn list_messages_returns_my_reaction() {
use crate::domain::models::ReactionKind;
let db = inmem_db().await;
let db_provider = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(limit_cfg()));
let chat_svc = build_chat_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let msg_svc = build_message_service(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let tenant_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let ctx = test_security_ctx_with_id(tenant_id, user_id);
let chat = chat_svc
.create_chat(
&ctx,
NewChat {
model: None,
title: Some("Reaction test".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat");
let scope = AccessScope::for_tenant(tenant_id);
let conn = db_provider.conn().expect("conn");
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");
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
let asst_msg_id = Uuid::now_v7();
message_repo
.insert_assistant_message(
&conn,
&scope,
InsertAssistantMessageParams {
id: asst_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");
let reaction_repo = OrmReactionRepository;
let reaction_scope = AccessScope::allow_all();
reaction_repo
.upsert(
&conn,
&reaction_scope,
UpsertReactionParams {
id: Uuid::now_v7(),
tenant_id,
message_id: asst_msg_id,
user_id,
reaction: ReactionKind::Like,
},
)
.await
.expect("upsert reaction");
let page = msg_svc
.list_messages(&ctx, chat.id, &ODataQuery::default())
.await
.expect("list_messages");
assert_eq!(page.items.len(), 2);
let user_msg = page
.items
.iter()
.find(|m| m.id == user_msg_id)
.expect("user msg");
assert_eq!(
user_msg.my_reaction, None,
"user message should have no reaction"
);
let asst_msg = page
.items
.iter()
.find(|m| m.id == asst_msg_id)
.expect("asst msg");
assert_eq!(
asst_msg.my_reaction,
Some(ReactionKind::Like),
"assistant message should have Like reaction"
);
}
#[tokio::test]
async fn list_messages_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_svc =
build_chat_service_tenant_only_authz(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let chat = chat_svc
.create_chat(
&ctx_a,
NewChat {
model: None,
title: Some("User A chat".to_owned()),
is_temporary: false,
},
)
.await
.expect("create_chat failed");
let msg_svc =
build_message_service_tenant_only_authz(Arc::clone(&db_provider), Arc::clone(&chat_repo));
let result = msg_svc
.list_messages(&ctx_b, chat.id, &ODataQuery::default())
.await;
assert!(
result.is_err(),
"Cross-owner list_messages must fail with tenant-only authz"
);
assert!(
matches!(result.unwrap_err(), DomainError::ChatNotFound { .. }),
"Expected ChatNotFound for cross-owner access with tenant-only authz"
);
}