use std::collections::HashMap;
use std::sync::Arc;
use bytes::Bytes;
use uuid::Uuid;
use crate::config::{ProviderEntry, RagConfig, StorageKind};
use crate::domain::repos::VectorStoreRepository as VectorStoreRepoTrait;
use crate::domain::service::test_helpers::{
MockModelResolver, MockOagwGateway, NoopOutboxEnqueuer, RecordingOutboxEnqueuer,
TestCatalogEntryParams, bytes_to_stream, inmem_db, insert_chat_for_user,
insert_chat_with_model, insert_test_message, mock_db_provider, mock_model_resolver,
mock_tenant_only_enforcer, test_catalog_entry,
};
use crate::infra::db::repo::{
chat_repo::ChatRepository as OrmChatRepository,
vector_store_repo::VectorStoreRepository as OrmVectorStoreRepository,
};
use crate::infra::llm::provider_resolver::ProviderResolver;
use crate::infra::llm::providers::ProviderKind;
use super::AttachmentService;
use crate::infra::db::repo::attachment_repo::AttachmentRepository as OrmAttachmentRepository;
type TestAttachmentService =
AttachmentService<OrmChatRepository, OrmAttachmentRepository, OrmVectorStoreRepository>;
fn test_provider_resolver(
oagw: &Arc<dyn oagw_sdk::ServiceGatewayClientV1>,
) -> Arc<ProviderResolver> {
let mut providers = HashMap::new();
providers.insert(
"openai".to_owned(),
ProviderEntry {
kind: ProviderKind::OpenAiResponses,
upstream_alias: Some("test-host".to_owned()),
host: "test-host".to_owned(),
port: None,
use_http: false,
api_path: "/v1/responses".to_owned(),
auth_plugin_type: None,
auth_config: None,
storage_backend: None,
supports_file_search_filters: true,
storage_kind: StorageKind::OpenAi,
api_version: None,
tenant_overrides: HashMap::new(),
},
);
Arc::new(ProviderResolver::new(oagw, providers))
}
fn build_service(
db: modkit_db::Db,
oagw: Arc<dyn oagw_sdk::ServiceGatewayClientV1>,
outbox: Arc<dyn crate::domain::repos::OutboxEnqueuer>,
rag_config: RagConfig,
) -> TestAttachmentService {
let db = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(modkit_db::odata::LimitCfg {
default: 20,
max: 100,
}));
let attachment_repo = Arc::new(OrmAttachmentRepository);
let vector_store_repo = Arc::new(OrmVectorStoreRepository);
let provider_resolver = test_provider_resolver(&(Arc::clone(&oagw) as _));
let rag_client =
Arc::new(crate::infra::llm::providers::rag_http_client::RagHttpClient::new(oagw));
let file_storage: Arc<dyn crate::domain::ports::FileStorageProvider> = Arc::new(
crate::infra::llm::providers::openai_file_storage::OpenAiFileStorage::new(
Arc::clone(&rag_client),
Arc::clone(&provider_resolver),
),
);
let vector_store_prov: Arc<dyn crate::domain::ports::VectorStoreProvider> = Arc::new(
crate::infra::llm::providers::openai_vector_store::OpenAiVectorStore::new(
rag_client,
Arc::clone(&provider_resolver),
),
);
AttachmentService::new(
db,
attachment_repo,
chat_repo,
vector_store_repo,
outbox,
mock_tenant_only_enforcer(),
file_storage,
vector_store_prov,
provider_resolver,
mock_model_resolver(),
rag_config,
crate::config::ThumbnailConfig::default(),
Arc::new(crate::domain::ports::metrics::NoopMetrics),
)
}
fn build_service_with_metrics(
db: modkit_db::Db,
oagw: Arc<dyn oagw_sdk::ServiceGatewayClientV1>,
outbox: Arc<dyn crate::domain::repos::OutboxEnqueuer>,
rag_config: RagConfig,
metrics: Arc<dyn crate::domain::ports::MiniChatMetricsPort>,
) -> TestAttachmentService {
let db = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(modkit_db::odata::LimitCfg {
default: 20,
max: 100,
}));
let attachment_repo = Arc::new(OrmAttachmentRepository);
let vector_store_repo = Arc::new(OrmVectorStoreRepository);
let provider_resolver = test_provider_resolver(&(Arc::clone(&oagw) as _));
let rag_client =
Arc::new(crate::infra::llm::providers::rag_http_client::RagHttpClient::new(oagw));
let file_storage: Arc<dyn crate::domain::ports::FileStorageProvider> = Arc::new(
crate::infra::llm::providers::openai_file_storage::OpenAiFileStorage::new(
Arc::clone(&rag_client),
Arc::clone(&provider_resolver),
),
);
let vector_store_prov: Arc<dyn crate::domain::ports::VectorStoreProvider> = Arc::new(
crate::infra::llm::providers::openai_vector_store::OpenAiVectorStore::new(
rag_client,
Arc::clone(&provider_resolver),
),
);
AttachmentService::new(
db,
attachment_repo,
chat_repo,
vector_store_repo,
outbox,
mock_tenant_only_enforcer(),
file_storage,
vector_store_prov,
provider_resolver,
mock_model_resolver(),
rag_config,
crate::config::ThumbnailConfig::default(),
metrics,
)
}
fn file_upload_response(file_id: &str) -> serde_json::Value {
serde_json::json!({ "id": file_id })
}
fn vector_store_create_response(vs_id: &str) -> serde_json::Value {
serde_json::json!({ "id": vs_id })
}
fn vector_store_add_file_response() -> serde_json::Value {
serde_json::json!({ "id": "vsf-abc123", "status": "in_progress" })
}
async fn test_upload_file_inner(
svc: &TestAttachmentService,
ctx: &modkit_security::SecurityContext,
chat_id: Uuid,
filename: &str,
content_type: &str,
data: Bytes,
size_hint: Option<u64>,
) -> Result<crate::infra::db::entity::attachment::Model, crate::domain::error::DomainError> {
use crate::domain::mime_validation::{
infer_mime_from_extension, normalize_mime, remap_csv_to_plain, validate_mime,
};
let upload_ctx = svc.get_upload_context(ctx, chat_id).await?;
let effective_ct = if normalize_mime(content_type) == "application/octet-stream" {
infer_mime_from_extension(filename).unwrap_or(content_type)
} else {
content_type
};
let effective_ct = if upload_ctx.allow_csv_upload {
remap_csv_to_plain(effective_ct).unwrap_or(effective_ct)
} else {
effective_ct
};
let validated = validate_mime(effective_ct)?;
let stream = bytes_to_stream(data);
svc.upload_file(
ctx,
chat_id,
upload_ctx,
filename.to_owned(),
validated.mime,
validated.kind,
stream,
size_hint,
)
.await
}
async fn test_upload_file(
svc: &TestAttachmentService,
ctx: &modkit_security::SecurityContext,
chat_id: Uuid,
filename: &str,
content_type: &str,
data: Bytes,
) -> Result<crate::infra::db::entity::attachment::Model, crate::domain::error::DomainError> {
let size = data.len() as u64;
test_upload_file_inner(svc, ctx, chat_id, filename, content_type, data, Some(size)).await
}
async fn test_upload_file_chunked(
svc: &TestAttachmentService,
ctx: &modkit_security::SecurityContext,
chat_id: Uuid,
filename: &str,
content_type: &str,
data: Bytes,
) -> Result<crate::infra::db::entity::attachment::Model, crate::domain::error::DomainError> {
test_upload_file_inner(svc, ctx, chat_id, filename, content_type, data, None).await
}
#[tokio::test]
async fn test_upload_document_full_lifecycle() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-uploaded-001")),
Ok(vector_store_create_response("vs-new-001")),
Ok(vector_store_add_file_response()),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"report.pdf",
"application/pdf",
Bytes::from(vec![0u8; 1024]),
)
.await;
assert!(result.is_ok(), "upload_file failed: {result:?}");
let attachment = result.unwrap();
assert_eq!(attachment.chat_id, chat_id);
assert_eq!(attachment.tenant_id, tenant_id);
assert_eq!(attachment.uploaded_by_user_id, user_id);
assert_eq!(attachment.filename, "report.pdf");
assert_eq!(attachment.content_type, "application/pdf");
assert_eq!(attachment.size_bytes, 1024);
assert_eq!(attachment.storage_backend, "openai");
assert_eq!(
attachment.provider_file_id.as_deref(),
Some("file-uploaded-001")
);
assert_eq!(
attachment.status,
crate::infra::db::entity::attachment::AttachmentStatus::Ready,
);
assert!(attachment.deleted_at.is_none());
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(requests.len(), 3, "expected 3 OAGW calls");
assert!(
requests[0].uri.contains("/v1/files"),
"1st call should be file upload, got: {}",
requests[0].uri
);
assert!(
requests[1].uri.contains("/v1/vector_stores"),
"2nd call should be vector store create, got: {}",
requests[1].uri
);
assert!(
requests[2]
.uri
.contains("/v1/vector_stores/vs-new-001/files"),
"3rd call should be add-file-to-VS, got: {}",
requests[2].uri
);
let add_file_body: serde_json::Value =
serde_json::from_str(&requests[2].body).expect("add-file body should be JSON");
assert_eq!(
add_file_body["file_id"], "file-uploaded-001",
"add-file should reference the uploaded file"
);
assert_eq!(
add_file_body["attributes"]["attachment_id"],
attachment.id.to_string(),
"add-file should tag with attachment_id"
);
}
#[tokio::test]
async fn test_upload_image_skips_vector_store() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![Ok(file_upload_response("file-img-001"))]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"photo.png",
"image/png",
Bytes::from(vec![0u8; 2048]),
)
.await;
assert!(result.is_ok(), "upload image failed: {result:?}");
let attachment = result.unwrap();
assert_eq!(
attachment.status,
crate::infra::db::entity::attachment::AttachmentStatus::Ready,
);
assert_eq!(attachment.content_type, "image/png");
assert_eq!(attachment.provider_file_id.as_deref(), Some("file-img-001"));
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(
requests.len(),
1,
"image upload should make only 1 OAGW call"
);
assert!(requests[0].uri.contains("/v1/files"));
}
#[tokio::test]
async fn test_second_upload_reuses_vector_store() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-001")),
Ok(vector_store_create_response("vs-reuse-001")),
Ok(vector_store_add_file_response()),
Ok(file_upload_response("file-002")),
Ok(vector_store_add_file_response()),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let r1 = test_upload_file(
&svc,
&ctx,
chat_id,
"a.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(r1.is_ok(), "1st upload failed: {r1:?}");
let r2 = test_upload_file(
&svc,
&ctx,
chat_id,
"b.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(r2.is_ok(), "2nd upload failed: {r2:?}");
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(requests.len(), 5, "expected 3 + 2 = 5 OAGW calls");
assert!(
requests[3].uri.contains("/v1/files"),
"4th call should be file upload, got: {}",
requests[3].uri
);
assert!(
requests[4]
.uri
.contains("/v1/vector_stores/vs-reuse-001/files"),
"5th call should reuse VS, got: {}",
requests[4].uri
);
}
#[tokio::test]
async fn test_upload_unsupported_mime_rejected() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]); let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"video.mp4",
"video/mp4",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(result.is_err());
let requests = oagw.captured_requests.lock().unwrap();
assert!(requests.is_empty(), "no OAGW calls for rejected MIME");
}
#[tokio::test]
async fn test_upload_chat_not_found() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let nonexistent_chat = Uuid::new_v4();
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
nonexistent_chat,
"doc.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(result.is_err(), "upload to nonexistent chat should fail");
}
#[tokio::test]
async fn test_upload_document_limit_exceeded() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let config = RagConfig {
max_documents_per_chat: 2,
max_total_upload_mb_per_chat: 100,
..RagConfig::default()
};
for _ in 0..2 {
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
}
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, config);
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"third.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(
err,
crate::domain::error::DomainError::DocumentLimitExceeded { .. }
),
"expected DocumentLimitExceeded, got: {err:?}"
);
}
#[tokio::test]
async fn test_upload_storage_limit_exceeded() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let config = RagConfig {
max_documents_per_chat: 50,
max_total_upload_mb_per_chat: 1, ..RagConfig::default()
};
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.size_bytes = 900_000; crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, config);
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"big.pdf",
"application/pdf",
Bytes::from(vec![0u8; 200_000]),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(
err,
crate::domain::error::DomainError::StorageLimitExceeded { .. }
),
"expected StorageLimitExceeded, got: {err:?}"
);
}
#[tokio::test]
async fn test_upload_storage_limit_exceeded_chunked() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let config = RagConfig {
max_documents_per_chat: 50,
max_total_upload_mb_per_chat: 1, ..RagConfig::default()
};
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.size_bytes = 900_000; crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![Ok(file_upload_response("file-chunked-001"))]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, config);
let result = test_upload_file_chunked(
&svc,
&ctx,
chat_id,
"big.pdf",
"application/pdf",
Bytes::from(vec![0u8; 200_000]),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(
err,
crate::domain::error::DomainError::StorageLimitExceeded { .. }
),
"expected StorageLimitExceeded for chunked upload, got: {err:?}"
);
}
#[tokio::test]
async fn test_upload_provider_failure_sets_failed() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw =
MockOagwGateway::single_error(oagw_sdk::error::ServiceGatewayError::ConnectionTimeout {
detail: "mock timeout".to_owned(),
instance: String::new(),
});
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"fail.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(result.is_err(), "upload should fail when provider errors");
assert!(
matches!(
result.unwrap_err(),
crate::domain::error::DomainError::ProviderError { .. }
),
"expected ProviderError"
);
}
#[tokio::test]
async fn test_get_attachment_returns_uploaded() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.get_attachment(&ctx, chat_id, att_id).await;
assert!(result.is_ok(), "get_attachment failed: {result:?}");
let att = result.unwrap();
assert_eq!(att.id, att_id);
assert_eq!(att.filename, "test.pdf");
}
#[tokio::test]
async fn test_get_attachment_soft_deleted_returns_not_found() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.deleted_at = Some(time::OffsetDateTime::now_utc());
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.get_attachment(&ctx, chat_id, att_id).await;
assert!(result.is_err(), "soft-deleted should return error");
assert!(
matches!(
result.unwrap_err(),
crate::domain::error::DomainError::NotFound { .. }
),
"expected NotFound"
);
}
#[tokio::test]
async fn test_delete_attachment_enqueues_cleanup() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(RecordingOutboxEnqueuer::new());
let outbox_ref = Arc::clone(&outbox);
let svc = build_service(
db,
Arc::clone(&oagw) as _,
outbox as _,
RagConfig::default(),
);
let result = svc.delete_attachment(&ctx, chat_id, att_id).await;
assert!(result.is_ok(), "delete_attachment failed: {result:?}");
let events = outbox_ref.cleanup_events.lock().unwrap();
assert_eq!(events.len(), 1, "should enqueue 1 cleanup event");
assert_eq!(events[0].attachment_id, att_id);
assert_eq!(events[0].event_type, "attachment_deleted");
}
#[tokio::test]
async fn test_delete_attachment_idempotent_already_deleted() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.deleted_at = Some(time::OffsetDateTime::now_utc());
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.delete_attachment(&ctx, chat_id, att_id).await;
assert!(result.is_ok(), "idempotent delete should succeed");
}
#[tokio::test]
async fn test_delete_attachment_wrong_user_not_found() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let other_user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, owner_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = owner_id;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx =
crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, other_user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.delete_attachment(&ctx, chat_id, att_id).await;
assert!(
matches!(
result.unwrap_err(),
crate::domain::error::DomainError::NotFound { .. }
),
"cross-owner delete must be masked as NotFound"
);
}
#[tokio::test]
async fn test_get_attachment_cross_owner_not_found() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let other_user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, owner_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = owner_id;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx =
crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, other_user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.get_attachment(&ctx, chat_id, att_id).await;
assert!(
matches!(
result.unwrap_err(),
crate::domain::error::DomainError::NotFound { .. }
),
"cross-owner get_attachment must be masked as NotFound"
);
}
#[tokio::test]
async fn test_upload_attachment_cross_owner_not_found() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let other_user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, owner_id).await;
let ctx =
crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, other_user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"test.pdf",
"application/pdf",
Bytes::from_static(b"dummy content"),
)
.await;
assert!(
matches!(
result.unwrap_err(),
crate::domain::error::DomainError::NotFound { .. }
),
"cross-owner upload must be masked as NotFound"
);
assert!(
oagw.captured_requests.lock().unwrap().is_empty(),
"cross-owner upload must fail before any provider call"
);
}
#[tokio::test]
async fn test_upload_mime_charset_stripped() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-txt-001")),
Ok(vector_store_create_response("vs-txt-001")),
Ok(vector_store_add_file_response()),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"notes.txt",
"text/plain; charset=utf-8", Bytes::from(vec![0u8; 100]),
)
.await;
assert!(
result.is_ok(),
"upload with charset param failed: {result:?}"
);
let attachment = result.unwrap();
assert_eq!(attachment.content_type, "text/plain");
assert_eq!(
attachment.attachment_kind,
crate::infra::db::entity::attachment::AttachmentKind::Document
);
}
#[tokio::test]
async fn test_upload_vector_store_indexing_fails() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-idx-fail")),
Ok(vector_store_create_response("vs-idx-fail")),
Err(oagw_sdk::error::ServiceGatewayError::ConnectionTimeout {
detail: "indexing timeout".to_owned(),
instance: String::new(),
}),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"big_doc.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(result.is_err(), "indexing failure should propagate");
assert!(
matches!(
result.unwrap_err(),
crate::domain::error::DomainError::ProviderError { .. }
),
"expected ProviderError for indexing failure"
);
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(requests.len(), 3, "should have attempted all 3 OAGW calls");
}
#[tokio::test]
async fn test_create_vector_store_failure_cleans_up_placeholder_row() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-vs-fail")),
Err(oagw_sdk::error::ServiceGatewayError::ConnectionTimeout {
detail: "VS create timeout".to_owned(),
instance: String::new(),
}),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(
db.clone(),
Arc::clone(&oagw) as _,
outbox,
RagConfig::default(),
);
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"test.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(result.is_err(), "VS create failure should propagate");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let conn = db_prov.conn().unwrap();
let scope = modkit_security::AccessScope::allow_all();
let vs_row = OrmVectorStoreRepository
.find_by_chat(&conn, &scope, chat_id)
.await
.unwrap();
assert!(
vs_row.is_none(),
"placeholder row should have been cleaned up after create_vector_store failure"
);
}
#[tokio::test]
async fn test_vector_store_failure_sets_attachment_failed() {
use crate::domain::repos::AttachmentRepository as _;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-vs-fail-2")),
Err(oagw_sdk::error::ServiceGatewayError::ConnectionTimeout {
detail: "VS create timeout".to_owned(),
instance: String::new(),
}),
Ok(serde_json::json!({"deleted": true})), ]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(
db.clone(),
Arc::clone(&oagw) as _,
outbox,
RagConfig::default(),
);
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"report.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(result.is_err(), "VS create failure should propagate");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let conn = db_prov.conn().unwrap();
let scope = modkit_security::AccessScope::allow_all();
let repo = OrmAttachmentRepository;
let ready_count: i64 = repo
.count_ready_documents(&conn, &scope, chat_id)
.await
.unwrap();
assert_eq!(ready_count, 0, "no ready docs expected after VS failure");
}
#[tokio::test]
async fn test_upload_concurrent_delete_during_upload() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.status = crate::infra::db::entity::attachment::AttachmentStatus::Pending;
params.deleted_at = Some(time::OffsetDateTime::now_utc());
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.get_attachment(&ctx, chat_id, att_id).await;
assert!(result.is_err());
assert!(
matches!(
result.unwrap_err(),
crate::domain::error::DomainError::NotFound { .. }
),
"soft-deleted pending attachment should return NotFound"
);
}
#[tokio::test]
async fn test_delete_attachment_referenced_by_message_conflict() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let message_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
insert_test_message(&db_prov, tenant_id, chat_id, message_id).await;
crate::domain::service::test_helpers::insert_test_message_attachment(
&db_prov, tenant_id, chat_id, message_id, att_id,
)
.await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.delete_attachment(&ctx, chat_id, att_id).await;
assert!(
result.is_err(),
"delete of referenced attachment should fail"
);
let err = result.unwrap_err();
assert!(
matches!(err, crate::domain::error::DomainError::Conflict { .. }),
"expected Conflict (attachment_locked), got: {err:?}"
);
}
#[tokio::test]
async fn test_delete_nonexistent_attachment_returns_not_found() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.delete_attachment(&ctx, chat_id, Uuid::new_v4()).await;
assert!(result.is_err());
assert!(
matches!(
result.unwrap_err(),
crate::domain::error::DomainError::NotFound { .. }
),
"non-existent attachment should return NotFound, not Forbidden"
);
}
#[tokio::test]
async fn test_get_ready_attachment_returns_detail() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.doc_summary = Some("Test summary".to_owned());
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let att = svc.get_attachment(&ctx, chat_id, att_id).await.unwrap();
assert_eq!(att.id, att_id);
assert_eq!(
att.status,
crate::infra::db::entity::attachment::AttachmentStatus::Ready
);
assert_eq!(att.doc_summary.as_deref(), Some("Test summary"));
assert!(att.deleted_at.is_none());
}
#[tokio::test]
async fn test_get_pending_attachment_returns_pending() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.status = crate::infra::db::entity::attachment::AttachmentStatus::Pending;
params.provider_file_id = None;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let att = svc.get_attachment(&ctx, chat_id, att_id).await.unwrap();
assert_eq!(
att.status,
crate::infra::db::entity::attachment::AttachmentStatus::Pending
);
assert!(att.doc_summary.is_none());
}
#[tokio::test]
async fn test_get_nonexistent_attachment_returns_not_found() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.get_attachment(&ctx, chat_id, Uuid::new_v4()).await;
assert!(result.is_err());
assert!(
matches!(
result.unwrap_err(),
crate::domain::error::DomainError::NotFound { .. }
),
"random UUID should return NotFound"
);
}
#[tokio::test]
async fn test_vector_store_created_on_first_document_upload() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-vs-001")),
Ok(vector_store_create_response("vs-winner-001")),
Ok(vector_store_add_file_response()),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(
db.clone(),
Arc::clone(&oagw) as _,
outbox,
RagConfig::default(),
);
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"doc.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(result.is_ok(), "upload failed: {result:?}");
let vs_repo = OrmVectorStoreRepository;
let conn = db_prov.conn().unwrap();
let scope = modkit_security::AccessScope::allow_all();
let vs_row = vs_repo.find_by_chat(&conn, &scope, chat_id).await.unwrap();
assert!(
vs_row.is_some(),
"vector store row should exist after document upload"
);
let vs_row = vs_row.unwrap();
assert_eq!(vs_row.vector_store_id.as_deref(), Some("vs-winner-001"));
}
#[tokio::test]
async fn test_vector_store_preexisting_row_reused() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
crate::domain::service::test_helpers::insert_test_vector_store(
&db_prov,
tenant_id,
chat_id,
Some("vs-preexisting".to_owned()),
)
.await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-pre-001")),
Ok(vector_store_add_file_response()),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"doc.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(
result.is_ok(),
"upload with preexisting VS failed: {result:?}"
);
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(requests.len(), 2, "should skip VS create when row exists");
assert!(requests[0].uri.contains("/v1/files"));
assert!(
requests[1]
.uri
.contains("/v1/vector_stores/vs-preexisting/files"),
"should use preexisting VS ID, got: {}",
requests[1].uri
);
}
#[tokio::test]
async fn test_storage_within_limit_after_deletions() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let config = RagConfig {
max_documents_per_chat: 50,
max_total_upload_mb_per_chat: 1, ..RagConfig::default()
};
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.size_bytes = 900_000; params.deleted_at = Some(time::OffsetDateTime::now_utc()); crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-after-del")),
Ok(vector_store_create_response("vs-after-del")),
Ok(vector_store_add_file_response()),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, config);
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"new.pdf",
"application/pdf",
Bytes::from(vec![0u8; 500_000]),
)
.await;
assert!(
result.is_ok(),
"upload should succeed when deleted rows free space: {result:?}"
);
}
#[tokio::test]
async fn test_cas_transition_chain_full_lifecycle() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-cas-chain")),
Ok(vector_store_create_response("vs-cas-chain")),
Ok(vector_store_add_file_response()),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let att = test_upload_file(
&svc,
&ctx,
chat_id,
"chain.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await
.expect("upload should succeed");
assert_eq!(
att.status,
crate::infra::db::entity::attachment::AttachmentStatus::Ready
);
assert_eq!(att.provider_file_id.as_deref(), Some("file-cas-chain"));
assert!(att.error_code.is_none());
}
#[tokio::test]
async fn test_provider_file_id_map_excludes_non_ready() {
use crate::domain::repos::AttachmentRepository;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut ready_params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
ready_params.uploaded_by_user_id = user_id;
ready_params.provider_file_id = Some("file-ready".to_owned());
let ready_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, ready_params).await;
let mut uploaded_params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
uploaded_params.uploaded_by_user_id = user_id;
uploaded_params.status = crate::infra::db::entity::attachment::AttachmentStatus::Uploaded;
uploaded_params.provider_file_id = Some("file-uploaded".to_owned());
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, uploaded_params).await;
let mut pending_params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
pending_params.uploaded_by_user_id = user_id;
pending_params.status = crate::infra::db::entity::attachment::AttachmentStatus::Pending;
pending_params.provider_file_id = None;
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, pending_params).await;
let repo = crate::infra::db::repo::attachment_repo::AttachmentRepository;
let conn = db_prov.conn().unwrap();
let scope = modkit_security::AccessScope::allow_all();
let map = repo
.build_provider_file_id_map(&conn, &scope, chat_id)
.await
.unwrap();
assert_eq!(map.len(), 1, "only ready attachment should be in map");
let att = map.get("file-ready").expect("file-ready should be in map");
assert_eq!(att.id, ready_id);
assert_eq!(att.filename, "test.pdf");
}
#[tokio::test]
async fn test_provider_file_id_map_excludes_deleted() {
use crate::domain::repos::AttachmentRepository;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.provider_file_id = Some("file-deleted".to_owned());
params.deleted_at = Some(time::OffsetDateTime::now_utc());
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let mut alive_params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
alive_params.uploaded_by_user_id = user_id;
alive_params.provider_file_id = Some("file-alive".to_owned());
let alive_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, alive_params).await;
let repo = crate::infra::db::repo::attachment_repo::AttachmentRepository;
let conn = db_prov.conn().unwrap();
let scope = modkit_security::AccessScope::allow_all();
let map = repo
.build_provider_file_id_map(&conn, &scope, chat_id)
.await
.unwrap();
assert_eq!(map.len(), 1, "deleted attachment should not be in map");
let att = map.get("file-alive").expect("file-alive should be in map");
assert_eq!(att.id, alive_id);
assert!(!map.contains_key("file-deleted"));
}
#[tokio::test]
async fn test_provider_file_id_map_empty_no_ready_docs() {
use crate::domain::repos::AttachmentRepository;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let repo = crate::infra::db::repo::attachment_repo::AttachmentRepository;
let conn = db_prov.conn().unwrap();
let scope = modkit_security::AccessScope::allow_all();
let map = repo
.build_provider_file_id_map(&conn, &scope, chat_id)
.await
.unwrap();
assert!(map.is_empty(), "no ready docs -> empty map");
}
#[tokio::test]
async fn test_get_attachment_wrong_chat_returns_not_found() {
use crate::domain::error::DomainError;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let other_chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
insert_chat_for_user(&db_prov, tenant_id, other_chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.get_attachment(&ctx, other_chat_id, att_id).await;
assert!(result.is_err(), "wrong chat_id should return error");
assert!(
matches!(result.unwrap_err(), DomainError::NotFound { .. }),
"should be NotFound"
);
}
#[tokio::test]
async fn test_delete_attachment_wrong_chat_returns_not_found() {
use crate::domain::error::DomainError;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let other_chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
insert_chat_for_user(&db_prov, tenant_id, other_chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.delete_attachment(&ctx, other_chat_id, att_id).await;
assert!(result.is_err(), "wrong chat_id should return error");
assert!(
matches!(result.unwrap_err(), DomainError::NotFound { .. }),
"should be NotFound"
);
}
#[tokio::test]
async fn test_enqueue_failure_rolls_back_soft_delete() {
use crate::domain::repos::AttachmentRepository;
use crate::domain::service::test_helpers::FailingOutboxEnqueuer;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox: Arc<dyn crate::domain::repos::OutboxEnqueuer> = Arc::new(FailingOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = svc.delete_attachment(&ctx, chat_id, att_id).await;
assert!(
result.is_err(),
"delete should fail when outbox enqueue fails"
);
let conn = db_prov.conn().unwrap();
let scope = modkit_security::AccessScope::allow_all();
let repo = OrmAttachmentRepository;
let row = repo.get(&conn, &scope, att_id).await.unwrap();
assert!(row.is_some(), "attachment should still exist");
let row = row.unwrap();
assert!(
row.deleted_at.is_none(),
"soft-delete should have been rolled back"
);
}
#[tokio::test]
async fn test_cas_set_failed_from_pending() {
use crate::domain::repos::AttachmentRepository;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.status = crate::infra::db::entity::attachment::AttachmentStatus::Pending;
params.provider_file_id = None;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let repo = crate::infra::db::repo::attachment_repo::AttachmentRepository;
let conn = db_prov.conn().unwrap();
let scope = modkit_security::AccessScope::allow_all();
let affected = repo
.cas_set_failed(
&conn,
&scope,
crate::domain::repos::SetFailedParams {
id: att_id,
error_code: "upload_failed".to_owned(),
from_status: "pending".to_owned(),
},
)
.await
.unwrap();
assert_eq!(affected, 1, "CAS pending->failed should affect 1 row");
let row = repo.get(&conn, &scope, att_id).await.unwrap().unwrap();
assert_eq!(
row.status,
crate::infra::db::entity::attachment::AttachmentStatus::Failed
);
assert_eq!(row.error_code.as_deref(), Some("upload_failed"));
}
#[tokio::test]
async fn test_cas_set_failed_from_uploaded() {
use crate::domain::repos::AttachmentRepository;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let mut params =
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
tenant_id, chat_id,
);
params.uploaded_by_user_id = user_id;
params.status = crate::infra::db::entity::attachment::AttachmentStatus::Uploaded;
let att_id =
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
let repo = crate::infra::db::repo::attachment_repo::AttachmentRepository;
let conn = db_prov.conn().unwrap();
let scope = modkit_security::AccessScope::allow_all();
let affected = repo
.cas_set_failed(
&conn,
&scope,
crate::domain::repos::SetFailedParams {
id: att_id,
error_code: "indexing_failed".to_owned(),
from_status: "uploaded".to_owned(),
},
)
.await
.unwrap();
assert_eq!(affected, 1, "CAS uploaded->failed should affect 1 row");
let row = repo.get(&conn, &scope, att_id).await.unwrap().unwrap();
assert_eq!(
row.status,
crate::infra::db::entity::attachment::AttachmentStatus::Failed
);
assert_eq!(row.error_code.as_deref(), Some("indexing_failed"));
}
#[test]
#[should_panic(expected = "attachment_in called with empty ids")]
fn test_attachment_in_panics_on_empty() {
use crate::domain::llm::FileSearchFilter;
drop(FileSearchFilter::attachment_in(&[]));
}
fn azure_model_resolver() -> Arc<dyn crate::domain::repos::ModelResolver> {
Arc::new(MockModelResolver::new(vec![test_catalog_entry(
TestCatalogEntryParams {
model_id: "gpt-5.2-azure".to_owned(),
provider_model_id: "gpt-5.2-2025-03-26".to_owned(),
display_name: "GPT-5.2 (Azure)".to_owned(),
tier: mini_chat_sdk::ModelTier::Premium,
enabled: true,
is_default: true,
input_tokens_credit_multiplier_micro: 2_000_000,
output_tokens_credit_multiplier_micro: 6_000_000,
multimodal_capabilities: vec![],
context_window: 128_000,
max_output_tokens: 16_384,
description: String::new(),
provider_display_name: "Azure OpenAI".to_owned(),
multiplier_display: "2x".to_owned(),
provider_id: "azure_openai".to_owned(),
},
)]))
}
fn dual_provider_resolver(
oagw: &Arc<dyn oagw_sdk::ServiceGatewayClientV1>,
) -> Arc<ProviderResolver> {
let mut providers = HashMap::new();
providers.insert(
"openai".to_owned(),
ProviderEntry {
kind: ProviderKind::OpenAiResponses,
upstream_alias: Some("test-host".to_owned()),
host: "test-host".to_owned(),
port: None,
use_http: false,
api_path: "/v1/responses".to_owned(),
auth_plugin_type: None,
auth_config: None,
storage_backend: None,
supports_file_search_filters: true,
storage_kind: StorageKind::OpenAi,
api_version: None,
tenant_overrides: HashMap::new(),
},
);
providers.insert(
"azure_openai".to_owned(),
ProviderEntry {
kind: ProviderKind::OpenAiResponses,
upstream_alias: Some("azure-host".to_owned()),
host: "azure-host".to_owned(),
port: None,
use_http: false,
api_path: "/v1/responses".to_owned(),
auth_plugin_type: None,
auth_config: None,
storage_backend: Some("azure".to_owned()),
supports_file_search_filters: false,
storage_kind: StorageKind::Azure,
api_version: Some("2024-10-21".to_owned()),
tenant_overrides: HashMap::new(),
},
);
Arc::new(ProviderResolver::new(oagw, providers))
}
fn build_service_azure(
db: modkit_db::Db,
oagw: Arc<dyn oagw_sdk::ServiceGatewayClientV1>,
outbox: Arc<dyn crate::domain::repos::OutboxEnqueuer>,
rag_config: RagConfig,
) -> TestAttachmentService {
let db = mock_db_provider(db);
let chat_repo = Arc::new(OrmChatRepository::new(modkit_db::odata::LimitCfg {
default: 20,
max: 100,
}));
let attachment_repo = Arc::new(OrmAttachmentRepository);
let vector_store_repo = Arc::new(OrmVectorStoreRepository);
let provider_resolver = dual_provider_resolver(&(Arc::clone(&oagw) as _));
let rag_client =
Arc::new(crate::infra::llm::providers::rag_http_client::RagHttpClient::new(oagw));
let mut file_impls: HashMap<String, Arc<dyn crate::domain::ports::FileStorageProvider>> =
HashMap::new();
let mut vs_impls: HashMap<String, Arc<dyn crate::domain::ports::VectorStoreProvider>> =
HashMap::new();
for (provider_id, entry) in provider_resolver.entries() {
let (file, vs): (
Arc<dyn crate::domain::ports::FileStorageProvider>,
Arc<dyn crate::domain::ports::VectorStoreProvider>,
) = match entry.storage_kind {
crate::config::StorageKind::Azure => {
let ver = entry
.api_version
.clone()
.expect("Azure requires api_version");
(
Arc::new(
crate::infra::llm::providers::azure_file_storage::AzureFileStorage::new(
Arc::clone(&rag_client),
Arc::clone(&provider_resolver),
ver.clone(),
),
),
Arc::new(
crate::infra::llm::providers::azure_vector_store::AzureVectorStore::new(
Arc::clone(&rag_client),
Arc::clone(&provider_resolver),
ver,
),
),
)
}
crate::config::StorageKind::OpenAi => (
Arc::new(
crate::infra::llm::providers::openai_file_storage::OpenAiFileStorage::new(
Arc::clone(&rag_client),
Arc::clone(&provider_resolver),
),
),
Arc::new(
crate::infra::llm::providers::openai_vector_store::OpenAiVectorStore::new(
Arc::clone(&rag_client),
Arc::clone(&provider_resolver),
),
),
),
};
file_impls.insert(provider_id.clone(), file);
vs_impls.insert(provider_id.clone(), vs);
}
let file_storage: Arc<dyn crate::domain::ports::FileStorageProvider> = Arc::new(
crate::infra::llm::providers::dispatching_storage::DispatchingFileStorage::new(file_impls),
);
let vector_store_prov: Arc<dyn crate::domain::ports::VectorStoreProvider> = Arc::new(
crate::infra::llm::providers::dispatching_storage::DispatchingVectorStore::new(vs_impls),
);
AttachmentService::new(
db,
attachment_repo,
chat_repo,
vector_store_repo,
outbox,
mock_tenant_only_enforcer(),
file_storage,
vector_store_prov,
provider_resolver,
azure_model_resolver(),
rag_config,
crate::config::ThumbnailConfig::default(),
Arc::new(crate::domain::ports::metrics::NoopMetrics),
)
}
#[tokio::test]
async fn test_upload_document_azure_provider_all_calls_same_alias() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_with_model(&db_prov, tenant_id, chat_id, user_id, "gpt-5.2-azure").await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-azure-001")),
Ok(vector_store_create_response("vs-azure-001")),
Ok(vector_store_add_file_response()),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service_azure(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"report.pdf",
"application/pdf",
Bytes::from(vec![0u8; 1024]),
)
.await;
assert!(result.is_ok(), "azure upload_file failed: {result:?}");
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(requests.len(), 3, "expected 3 OAGW calls for azure upload");
for (i, req) in requests.iter().enumerate() {
assert!(
req.uri.starts_with("/azure-host/"),
"call {i} should use azure-host alias, got URI: {}",
req.uri
);
}
assert!(
requests[0].uri.contains("/openai/files"),
"1st: file upload"
);
assert!(
requests[1].uri.contains("/openai/vector_stores") && !requests[1].uri.contains("/files"),
"2nd: VS create"
);
assert!(
requests[2]
.uri
.contains("/openai/vector_stores/vs-azure-001/files"),
"3rd: add file to VS"
);
}
#[tokio::test]
async fn test_upload_document_azure_storage_backend_and_vs_provider_persisted() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_with_model(&db_prov, tenant_id, chat_id, user_id, "gpt-5.2-azure").await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-azure-002")),
Ok(vector_store_create_response("vs-azure-002")),
Ok(vector_store_add_file_response()),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service_azure(
db.clone(),
Arc::clone(&oagw) as _,
outbox,
RagConfig::default(),
);
let attachment = test_upload_file(
&svc,
&ctx,
chat_id,
"doc.pdf",
"application/pdf",
Bytes::from(vec![0u8; 512]),
)
.await
.expect("azure upload should succeed");
assert_eq!(
attachment.storage_backend, "azure",
"storage_backend should be 'azure' (resolved from config), not 'azure_openai'"
);
let conn = db_prov.conn().unwrap();
let scope = modkit_security::AccessScope::allow_all();
let vs_row = OrmVectorStoreRepository
.find_by_chat(&conn, &scope, chat_id)
.await
.expect("VS query should succeed")
.expect("VS row should exist after upload");
assert_eq!(
vs_row.provider, "azure",
"VS provider should be 'azure' matching storage_backend"
);
}
#[tokio::test]
async fn test_second_upload_provider_mismatch_rejected() {
use crate::domain::service::test_helpers::insert_test_vector_store_with_provider;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
insert_test_vector_store_with_provider(
&db_prov,
tenant_id,
chat_id,
Some("vs-pre-existing".to_owned()),
"azure",
)
.await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-oa-mismatch")),
]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, RagConfig::default());
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"b.pdf",
"application/pdf",
Bytes::from(vec![0u8; 100]),
)
.await;
assert!(result.is_err(), "provider mismatch should be rejected");
let err = result.unwrap_err();
let err_str = format!("{err:?}");
assert!(
err_str.contains("provider_mismatch") || err_str.contains("mismatch"),
"error should mention provider mismatch, got: {err_str}"
);
}
use crate::domain::ports::FileStorageProvider;
#[tokio::test]
async fn test_rag_http_client_multipart_uses_params_purpose() {
let oagw = MockOagwGateway::with_responses(vec![Ok(file_upload_response("file-001"))]);
let client = Arc::new(
crate::infra::llm::providers::rag_http_client::RagHttpClient::new(Arc::clone(&oagw) as _),
);
let tenant_id = Uuid::new_v4();
let ctx = crate::domain::service::test_helpers::test_security_ctx(tenant_id);
let params = crate::domain::ports::UploadFileParams {
filename: "test.txt".to_owned(),
content_type: "text/plain".to_owned(),
file_stream: bytes_to_stream(Bytes::from("hello")),
purpose: "user_data".to_owned(),
};
let result = client
.multipart_upload(ctx, "/test-host/v1/files", params)
.await;
assert!(result.is_ok(), "upload failed: {result:?}");
assert_eq!(result.unwrap().0, "file-001");
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(requests.len(), 1);
let body = &requests[0].body;
let body_str = String::from_utf8_lossy(body.as_bytes());
assert!(
body_str.contains("user_data"),
"multipart body should contain custom purpose 'user_data', got: {body_str}"
);
assert!(
!body_str.contains("assistants"),
"multipart body should NOT contain hardcoded 'assistants'"
);
}
#[tokio::test]
async fn test_rag_http_client_json_post_parses_response() {
#[derive(serde::Deserialize)]
struct Resp {
id: String,
}
let response_json = serde_json::json!({ "id": "vs-001" });
let oagw = MockOagwGateway::with_responses(vec![Ok(response_json)]);
let client = Arc::new(
crate::infra::llm::providers::rag_http_client::RagHttpClient::new(Arc::clone(&oagw) as _),
);
let tenant_id = Uuid::new_v4();
let ctx = crate::domain::service::test_helpers::test_security_ctx(tenant_id);
let result: Result<Resp, _> = client
.json_post(ctx, "/test-host/v1/vector_stores", &serde_json::json!({}))
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap().id, "vs-001");
}
#[tokio::test]
async fn test_openai_file_storage_uri_pattern() {
let oagw = MockOagwGateway::with_responses(vec![Ok(file_upload_response("file-001"))]);
let resolver = test_provider_resolver(&(Arc::clone(&oagw) as _));
let rag_client = Arc::new(
crate::infra::llm::providers::rag_http_client::RagHttpClient::new(Arc::clone(&oagw) as _),
);
let storage = crate::infra::llm::providers::openai_file_storage::OpenAiFileStorage::new(
rag_client, resolver,
);
let tenant_id = Uuid::new_v4();
let ctx = crate::domain::service::test_helpers::test_security_ctx(tenant_id);
let params = crate::domain::ports::UploadFileParams {
filename: "test.txt".to_owned(),
content_type: "text/plain".to_owned(),
file_stream: bytes_to_stream(Bytes::from("hello")),
purpose: "assistants".to_owned(),
};
let result = storage.upload_file(ctx, "openai", params).await;
assert!(result.is_ok(), "upload failed: {result:?}");
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert!(
requests[0].uri.starts_with("/test-host/v1/files"),
"OpenAI URI should be /{{alias}}/v1/files, got: {}",
requests[0].uri
);
assert!(
!requests[0].uri.contains("api-version"),
"OpenAI URI should NOT have api-version query param"
);
}
#[tokio::test]
async fn test_azure_file_storage_uri_pattern() {
let oagw = MockOagwGateway::with_responses(vec![Ok(file_upload_response("file-az-001"))]);
let resolver = dual_provider_resolver(&(Arc::clone(&oagw) as _));
let rag_client = Arc::new(
crate::infra::llm::providers::rag_http_client::RagHttpClient::new(Arc::clone(&oagw) as _),
);
let storage = crate::infra::llm::providers::azure_file_storage::AzureFileStorage::new(
rag_client,
resolver,
"2025-03-01-preview".to_owned(),
);
let tenant_id = Uuid::new_v4();
let ctx = crate::domain::service::test_helpers::test_security_ctx(tenant_id);
let params = crate::domain::ports::UploadFileParams {
filename: "test.txt".to_owned(),
content_type: "text/plain".to_owned(),
file_stream: bytes_to_stream(Bytes::from("hello")),
purpose: "assistants".to_owned(),
};
let result = storage.upload_file(ctx, "azure_openai", params).await;
assert!(result.is_ok(), "upload failed: {result:?}");
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert!(
requests[0].uri.starts_with("/azure-host/openai/files"),
"Azure URI should be /{{alias}}/openai/files, got: {}",
requests[0].uri
);
assert!(
requests[0].uri.contains("api-version=2025-03-01-preview"),
"Azure URI should have api-version query param, got: {}",
requests[0].uri
);
}
#[tokio::test]
async fn test_dispatching_file_storage_routes_correctly() {
let oagw = MockOagwGateway::with_responses(vec![
Ok(file_upload_response("file-oai-001")),
Ok(file_upload_response("file-az-001")),
]);
let resolver = dual_provider_resolver(&(Arc::clone(&oagw) as _));
let rag_client = Arc::new(
crate::infra::llm::providers::rag_http_client::RagHttpClient::new(Arc::clone(&oagw) as _),
);
let mut impls: HashMap<String, Arc<dyn crate::domain::ports::FileStorageProvider>> =
HashMap::new();
impls.insert(
"openai".to_owned(),
Arc::new(
crate::infra::llm::providers::openai_file_storage::OpenAiFileStorage::new(
Arc::clone(&rag_client),
Arc::clone(&resolver),
),
),
);
impls.insert(
"azure_openai".to_owned(),
Arc::new(
crate::infra::llm::providers::azure_file_storage::AzureFileStorage::new(
rag_client,
resolver,
"2024-10-21".to_owned(),
),
),
);
let dispatch =
crate::infra::llm::providers::dispatching_storage::DispatchingFileStorage::new(impls);
let tenant_id = Uuid::new_v4();
let ctx = crate::domain::service::test_helpers::test_security_ctx(tenant_id);
let r1: Result<(String, u64), _> = dispatch
.upload_file(
ctx.clone(),
"openai",
crate::domain::ports::UploadFileParams {
filename: "test.txt".to_owned(),
content_type: "text/plain".to_owned(),
file_stream: bytes_to_stream(Bytes::from("hello")),
purpose: "assistants".to_owned(),
},
)
.await;
assert!(r1.is_ok());
assert_eq!(r1.unwrap().0, "file-oai-001");
let r2: Result<(String, u64), _> = dispatch
.upload_file(
ctx.clone(),
"azure_openai",
crate::domain::ports::UploadFileParams {
filename: "test.txt".to_owned(),
content_type: "text/plain".to_owned(),
file_stream: bytes_to_stream(Bytes::from("hello")),
purpose: "assistants".to_owned(),
},
)
.await;
assert!(r2.is_ok());
assert_eq!(r2.unwrap().0, "file-az-001");
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert!(
requests[0].uri.contains("/v1/files"),
"first request should use /v1/ pattern, got: {}",
requests[0].uri
);
assert!(
requests[1].uri.contains("/openai/files"),
"second request should use /openai/ pattern, got: {}",
requests[1].uri
);
}
#[tokio::test]
async fn test_dispatching_file_storage_unknown_provider_returns_error() {
let dispatch = crate::infra::llm::providers::dispatching_storage::DispatchingFileStorage::new(
HashMap::new(),
);
let tenant_id = Uuid::new_v4();
let ctx = crate::domain::service::test_helpers::test_security_ctx(tenant_id);
let params = crate::domain::ports::UploadFileParams {
filename: "test.txt".to_owned(),
content_type: "text/plain".to_owned(),
file_stream: bytes_to_stream(Bytes::from("hello")),
purpose: "assistants".to_owned(),
};
let result: Result<(String, u64), _> = dispatch.upload_file(ctx, "nonexistent", params).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(
err,
crate::domain::ports::FileStorageError::Configuration { .. }
),
"expected Configuration error for unknown provider, got: {err:?}"
);
}
#[tokio::test]
async fn test_openai_file_storage_uses_tenant_specific_alias() {
use crate::config::ProviderTenantOverride;
use crate::infra::llm::providers::ProviderKind;
let mut providers = HashMap::new();
let mut tenant_overrides = HashMap::new();
let tenant_id = Uuid::new_v4();
tenant_overrides.insert(
tenant_id.to_string(),
ProviderTenantOverride {
host: Some("tenant-specific.openai.com".to_owned()),
upstream_alias: Some("tenant-alias".to_owned()),
auth_plugin_type: None,
auth_config: None,
},
);
providers.insert(
"openai".to_owned(),
ProviderEntry {
kind: ProviderKind::OpenAiResponses,
upstream_alias: Some("default-alias".to_owned()),
host: "api.openai.com".to_owned(),
port: None,
use_http: false,
api_path: "/v1/responses".to_owned(),
auth_plugin_type: None,
auth_config: None,
storage_backend: None,
supports_file_search_filters: true,
storage_kind: StorageKind::OpenAi,
api_version: None,
tenant_overrides,
},
);
let oagw = MockOagwGateway::with_responses(vec![Ok(file_upload_response("file-001"))]);
let resolver = Arc::new(ProviderResolver::new(&(Arc::clone(&oagw) as _), providers));
let rag_client = Arc::new(
crate::infra::llm::providers::rag_http_client::RagHttpClient::new(Arc::clone(&oagw) as _),
);
let storage = crate::infra::llm::providers::openai_file_storage::OpenAiFileStorage::new(
rag_client, resolver,
);
let user_id = Uuid::new_v4();
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let params = crate::domain::ports::UploadFileParams {
filename: "test.txt".to_owned(),
content_type: "text/plain".to_owned(),
file_stream: bytes_to_stream(Bytes::from("hello")),
purpose: "assistants".to_owned(),
};
let result = storage.upload_file(ctx, "openai", params).await;
assert!(result.is_ok(), "upload failed: {result:?}");
let requests = oagw.captured_requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert!(
requests[0].uri.starts_with("/tenant-alias/v1/files"),
"should use tenant-specific alias 'tenant-alias', got: {}",
requests[0].uri
);
}
#[tokio::test]
async fn test_upload_limits_ccm_tighter_than_configmap() {
use mini_chat_sdk::ModelTier;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let mut entry = test_catalog_entry(TestCatalogEntryParams {
model_id: "gpt-5.2".to_owned(),
provider_model_id: "gpt-5.2-2025-03-26".to_owned(),
display_name: "GPT 5.2".to_owned(),
tier: ModelTier::Standard,
enabled: true,
is_default: true,
input_tokens_credit_multiplier_micro: 1_000_000,
output_tokens_credit_multiplier_micro: 3_000_000,
multimodal_capabilities: vec![],
context_window: 128_000,
max_output_tokens: 16_384,
description: String::new(),
provider_display_name: "OpenAI".to_owned(),
multiplier_display: "1x".to_owned(),
provider_id: "openai".to_owned(),
});
entry.general_config.max_file_size_mb = 10;
let model_resolver: Arc<dyn crate::domain::repos::ModelResolver> =
Arc::new(MockModelResolver::new(vec![entry]));
let oagw = MockOagwGateway::with_responses(vec![]);
let db_prov_arc = mock_db_provider(db.clone());
let provider_resolver = test_provider_resolver(&(Arc::clone(&oagw) as _));
let rag_config = RagConfig::default();
let svc =
AttachmentService::new(
db_prov_arc,
Arc::new(OrmAttachmentRepository),
Arc::new(OrmChatRepository::new(modkit_db::odata::LimitCfg {
default: 20,
max: 100,
})),
Arc::new(OrmVectorStoreRepository),
Arc::new(NoopOutboxEnqueuer),
mock_tenant_only_enforcer(),
Arc::new(
crate::infra::llm::providers::openai_file_storage::OpenAiFileStorage::new(
Arc::new(
crate::infra::llm::providers::rag_http_client::RagHttpClient::new(
Arc::clone(&oagw) as _,
),
),
Arc::clone(&provider_resolver),
),
),
Arc::new(
crate::infra::llm::providers::openai_vector_store::OpenAiVectorStore::new(
Arc::new(
crate::infra::llm::providers::rag_http_client::RagHttpClient::new(
Arc::clone(&oagw) as _,
),
),
Arc::clone(&provider_resolver),
),
),
provider_resolver,
model_resolver,
rag_config,
crate::config::ThumbnailConfig::default(),
Arc::new(crate::domain::ports::metrics::NoopMetrics),
);
let upload_ctx = svc.get_upload_context(&ctx, chat_id).await.unwrap();
assert_eq!(upload_ctx.limits.max_file_bytes, 10_485_760);
assert_eq!(upload_ctx.limits.max_image_bytes, 5_242_880);
}
#[tokio::test]
async fn test_upload_limits_configmap_tighter_than_ccm() {
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let rag_config = RagConfig {
uploaded_file_max_size_kb: 1,
..RagConfig::default()
};
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, rag_config);
let upload_ctx = svc.get_upload_context(&ctx, chat_id).await.unwrap();
assert_eq!(upload_ctx.limits.max_file_bytes, 1024);
}
#[tokio::test]
async fn upload_image_emits_metrics_and_gauge_balanced() {
use crate::domain::service::test_helpers::TestMetrics;
use std::sync::atomic::Ordering;
let db = inmem_db().await;
let tenant_id = Uuid::new_v4();
let chat_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let db_prov = mock_db_provider(db.clone());
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
let oagw = MockOagwGateway::with_responses(vec![Ok(file_upload_response("file-img-m1"))]);
let outbox = Arc::new(NoopOutboxEnqueuer);
let metrics = Arc::new(TestMetrics::new());
let svc = build_service_with_metrics(
db,
Arc::clone(&oagw) as _,
outbox,
RagConfig::default(),
Arc::clone(&metrics) as _,
);
let result = test_upload_file(
&svc,
&ctx,
chat_id,
"photo.png",
"image/png",
Bytes::from(vec![0u8; 2048]),
)
.await;
assert!(result.is_ok(), "upload should succeed: {result:?}");
assert_eq!(
metrics.attachment_upload.load(Ordering::Relaxed),
1,
"should record attachment_upload counter"
);
assert_eq!(
metrics.attachment_upload_bytes.load(Ordering::Relaxed),
1,
"should record attachment_upload_bytes histogram"
);
assert_eq!(
metrics.attachments_pending.load(Ordering::Relaxed),
0,
"pending gauge should be back to zero (guard balanced)"
);
}