document updates

This commit is contained in:
2025-10-27 22:06:33 +01:00
parent a6da34740f
commit 50125f4659
3 changed files with 588 additions and 37 deletions
+403
View File
@@ -4,6 +4,7 @@ use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use uuid::Uuid;
#[derive(Deserialize)]
@@ -19,6 +20,7 @@ struct DocumentInfo {
original_name: String,
deleted_at: Option<String>,
issued_at: Option<String>,
metadata: Value,
tags: Vec<TagSummary>,
#[serde(default)]
correspondents: Vec<DocumentCorrespondentInfo>,
@@ -103,6 +105,13 @@ struct AnalyzeJobPayload {
force: bool,
}
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
#[serde(default)]
code: Option<String>,
}
#[derive(Deserialize)]
struct FolderResponse {
folder: FolderInfo,
@@ -1229,3 +1238,397 @@ async fn bulk_reanalyze_selected_documents() -> Result<()> {
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn patch_document_updates_title_and_handles_conflict() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "patch-title";
app.insert_user("editor", password, "admin").await?;
let token = app.login_token("editor", password).await?;
let first_upload = app
.upload_document(
"/api/documents",
"report.pdf",
"application/pdf",
b"fake pdf contents",
None,
&token,
)
.await?;
let first_body = body_to_vec(first_upload.into_body()).await?;
let mut first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let update = app
.patch_json(
&format!("/api/documents/{}", first_detail.document.id),
&json!({
"title": "Quarterly Summary"
}),
Some(&token),
)
.await?;
assert_eq!(update.status(), StatusCode::OK);
let update_body = body_to_vec(update.into_body()).await?;
first_detail = serde_json::from_slice(&update_body)?;
assert_eq!(first_detail.document.title, "Quarterly Summary");
assert_eq!(first_detail.document.filename, "Quarterly Summary.pdf");
let second_upload = app
.upload_document(
"/api/documents",
"notes.pdf",
"application/pdf",
b"other pdf",
None,
&token,
)
.await?;
let second_body = body_to_vec(second_upload.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
let conflict = app
.patch_json(
&format!("/api/documents/{}", second_detail.document.id),
&json!({
"title": "Quarterly Summary"
}),
Some(&token),
)
.await?;
assert_eq!(conflict.status(), StatusCode::CONFLICT);
let conflict_body = body_to_vec(conflict.into_body()).await?;
let conflict_json: ErrorResponse = serde_json::from_slice(&conflict_body)?;
assert_eq!(conflict_json.code.as_deref(), Some("duplicate_filename"));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn patch_document_updates_and_clears_issued_at() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "patch-issued";
app.insert_user("scheduler", password, "admin").await?;
let token = app.login_token("scheduler", password).await?;
let upload = app
.upload_document(
"/api/documents",
"invoice.txt",
"text/plain",
b"invoice contents",
None,
&token,
)
.await?;
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
assert!(detail.document.issued_at.is_none());
let issued_at = "2021-02-03T04:05:06Z";
let response = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({
"issued_at": issued_at
}),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let patched: DocumentDetail = serde_json::from_slice(&body)?;
assert_eq!(
patched.document.issued_at.as_deref(),
Some("2021-02-03T04:05:06+00:00")
);
let cleared = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({
"issued_at": null
}),
Some(&token),
)
.await?;
let cleared_status = cleared.status();
let cleared_body = body_to_vec(cleared.into_body()).await?;
assert!(
cleared_status == StatusCode::OK,
"clear issued_at failed status {} body {}",
cleared_status,
String::from_utf8_lossy(&cleared_body)
);
let cleared_detail: DocumentDetail = serde_json::from_slice(&cleared_body)?;
assert!(cleared_detail.document.issued_at.is_none());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn patch_document_metadata_merge_and_replace() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "patch-meta";
app.insert_user("curator", password, "admin").await?;
let token = app.login_token("curator", password).await?;
let initial_metadata = r#"{"existing":{"keep":true},"other":1}"#;
let upload = app
.upload_document_with_options(
"/api/documents",
"meta.txt",
"text/plain",
b"meta",
None,
None,
Some(initial_metadata),
&token,
)
.await?;
let upload_body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
assert_eq!(
detail.document.metadata,
json!({
"existing": {"keep": true},
"other": 1
})
);
let merge_response = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({
"metadata": {
"value": {
"existing": {"update": 5},
"added": "new"
}
}
}),
Some(&token),
)
.await?;
assert_eq!(merge_response.status(), StatusCode::OK);
let merge_body = body_to_vec(merge_response.into_body()).await?;
let merged: DocumentDetail = serde_json::from_slice(&merge_body)?;
assert_eq!(
merged.document.metadata,
json!({
"existing": {"keep": true, "update": 5},
"other": 1,
"added": "new"
})
);
let replace_response = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({
"metadata": {
"replace": true,
"value": {"fresh": true}
}
}),
Some(&token),
)
.await?;
assert_eq!(replace_response.status(), StatusCode::OK);
let replace_body = body_to_vec(replace_response.into_body()).await?;
let replaced: DocumentDetail = serde_json::from_slice(&replace_body)?;
assert_eq!(replaced.document.metadata, json!({"fresh": true}));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn patch_document_validation_errors() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "patch-errors";
app.insert_user("auditor", password, "admin").await?;
let token = app.login_token("auditor", password).await?;
let upload = app
.upload_document(
"/api/documents",
"errors.txt",
"text/plain",
b"errors",
None,
&token,
)
.await?;
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
let empty_title = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({ "title": " " }),
Some(&token),
)
.await?;
assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST);
let title_body = body_to_vec(empty_title.into_body()).await?;
let title_error: ErrorResponse = serde_json::from_slice(&title_body)?;
assert_eq!(title_error.error, "title must not be empty");
let empty_issued = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({ "issued_at": "" }),
Some(&token),
)
.await?;
assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST);
let issued_body = body_to_vec(empty_issued.into_body()).await?;
let issued_error: ErrorResponse = serde_json::from_slice(&issued_body)?;
assert_eq!(issued_error.error, "issued_at must not be empty");
let invalid_merge = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({
"metadata": {
"value": 5
}
}),
Some(&token),
)
.await?;
assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST);
let merge_body = body_to_vec(invalid_merge.into_body()).await?;
let merge_error: ErrorResponse = serde_json::from_slice(&merge_body)?;
assert_eq!(
merge_error.error,
"metadata value must be a JSON object when replace is false"
);
let replace_scalar = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({
"metadata": {
"replace": true,
"value": 5
}
}),
Some(&token),
)
.await?;
assert_eq!(replace_scalar.status(), StatusCode::OK);
let replace_body = body_to_vec(replace_scalar.into_body()).await?;
let replace_detail: DocumentDetail = serde_json::from_slice(&replace_body)?;
assert_eq!(replace_detail.document.metadata, json!(5));
let merge_after_scalar = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({
"metadata": {
"value": { "new": 1 }
}
}),
Some(&token),
)
.await?;
assert_eq!(merge_after_scalar.status(), StatusCode::BAD_REQUEST);
let merge_after_body = body_to_vec(merge_after_scalar.into_body()).await?;
let merge_after_error: ErrorResponse = serde_json::from_slice(&merge_after_body)?;
assert_eq!(
merge_after_error.error,
"existing metadata is not an object; set replace=true to overwrite"
);
let malformed_timestamp = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({ "issued_at": "not-a-timestamp" }),
Some(&token),
)
.await?;
assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST);
let malformed_body = body_to_vec(malformed_timestamp.into_body()).await?;
let malformed_error: ErrorResponse = serde_json::from_slice(&malformed_body)?;
assert!(
malformed_error
.error
.starts_with("issued_at must be an RFC3339 timestamp"),
"unexpected error: {}",
malformed_error.error
);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn patch_document_updates_multiple_fields() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "patch-multi";
app.insert_user("planner", password, "admin").await?;
let token = app.login_token("planner", password).await?;
let upload = app
.upload_document(
"/api/documents",
"multi.pdf",
"application/pdf",
b"multi",
None,
&token,
)
.await?;
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
let response = app
.patch_json(
&format!("/api/documents/{}", detail.document.id),
&json!({
"title": "Annual Report",
"issued_at": "2022-05-01T12:00:00Z",
"metadata": {
"value": {
"department": "finance",
"year": 2022
}
}
}),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let response_body = body_to_vec(response.into_body()).await?;
let updated: DocumentDetail = serde_json::from_slice(&response_body)?;
assert_eq!(updated.document.title, "Annual Report");
assert_eq!(updated.document.filename, "Annual Report.pdf");
assert_eq!(
updated.document.issued_at.as_deref(),
Some("2022-05-01T12:00:00+00:00")
);
assert_eq!(
updated.document.metadata,
json!({
"department": "finance",
"year": 2022
})
);
app.cleanup().await?;
Ok(())
}