document updates
This commit is contained in:
@@ -687,10 +687,22 @@ pub mod schemas {
|
|||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct DocumentMetadataUpdate {
|
||||||
|
pub value: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
#[schema(default = false)]
|
||||||
|
pub replace: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UpdateDocumentRequest {
|
pub struct UpdateDocumentRequest {
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
|
#[schema(nullable, value_type = Option<String>)]
|
||||||
|
pub issued_at: Option<Value>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub metadata: Option<DocumentMetadataUpdate>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
+173
-37
@@ -11,7 +11,7 @@ use diesel::dsl::exists;
|
|||||||
use diesel::{prelude::*, result::DatabaseErrorKind, select, PgConnection};
|
use diesel::{prelude::*, result::DatabaseErrorKind, select, PgConnection};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, map::Entry, Map, Value};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
@@ -34,6 +34,7 @@ use crate::state::AppState;
|
|||||||
use crate::utils::{
|
use crate::utils::{
|
||||||
db::{no_content, validate_bulk_ids, IntoJsonResponse},
|
db::{no_content, validate_bulk_ids, IntoJsonResponse},
|
||||||
http::inline_content_disposition,
|
http::inline_content_disposition,
|
||||||
|
json::{classify_nullable, NullableValue},
|
||||||
storage_paths::document_version_object_key,
|
storage_paths::document_version_object_key,
|
||||||
time::to_iso,
|
time::to_iso,
|
||||||
validation::ensure_exists,
|
validation::ensure_exists,
|
||||||
@@ -225,9 +226,24 @@ pub struct BulkMoveRequest {
|
|||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct DocumentMetadataUpdate {
|
||||||
|
pub value: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
#[schema(default = false)]
|
||||||
|
pub replace: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct UpdateDocumentRequest {
|
pub struct UpdateDocumentRequest {
|
||||||
|
#[serde(default)]
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[schema(nullable, value_type = Option<String>)]
|
||||||
|
pub issued_at: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub metadata: Option<DocumentMetadataUpdate>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -274,6 +290,16 @@ pub struct AssignCorrespondentsRequest {
|
|||||||
pub replace: bool,
|
pub replace: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, AsChangeset)]
|
||||||
|
#[diesel(table_name = documents)]
|
||||||
|
struct DocumentUpdateChangeset {
|
||||||
|
title: Option<String>,
|
||||||
|
filename: Option<String>,
|
||||||
|
issued_at: Option<Option<NaiveDateTime>>,
|
||||||
|
metadata: Option<Value>,
|
||||||
|
updated_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
|
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum BulkCorrespondentAction {
|
pub enum BulkCorrespondentAction {
|
||||||
@@ -1220,7 +1246,7 @@ pub async fn update_document(
|
|||||||
user_id,
|
user_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<UpdateDocumentRequest>,
|
Json(payload): Json<Value>,
|
||||||
) -> AppResult<Json<DocumentDetailResponse>> {
|
) -> AppResult<Json<DocumentDetailResponse>> {
|
||||||
let mut document: Document = documents::table
|
let mut document: Document = documents::table
|
||||||
.find(document_id)
|
.find(document_id)
|
||||||
@@ -1230,52 +1256,113 @@ pub async fn update_document(
|
|||||||
return Err(AppError::not_found());
|
return Err(AppError::not_found());
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_title = match payload.title {
|
let payload_obj = payload
|
||||||
Some(ref title) => {
|
.as_object()
|
||||||
let trimmed = title.trim();
|
.ok_or_else(|| AppError::bad_request("request body must be a JSON object"))?;
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Err(AppError::bad_request("title must not be empty"));
|
let title = match payload_obj.get("title") {
|
||||||
}
|
None | Some(Value::Null) => None,
|
||||||
Some(trimmed.to_string())
|
Some(Value::String(value)) => Some(value.clone()),
|
||||||
}
|
Some(_) => return Err(AppError::bad_request("title must be a string")),
|
||||||
None => None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if new_title.is_none() {
|
let issued_at_class =
|
||||||
|
classify_nullable(payload_obj.get("issued_at")).map_err(AppError::bad_request)?;
|
||||||
|
|
||||||
|
let metadata = match payload_obj.get("metadata") {
|
||||||
|
None | Some(Value::Null) => None,
|
||||||
|
Some(value) => Some(
|
||||||
|
serde_json::from_value::<DocumentMetadataUpdate>(value.clone())
|
||||||
|
.map_err(|err| AppError::bad_request(format!("invalid metadata payload: {err}")))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut changes = DocumentUpdateChangeset::default();
|
||||||
|
let mut has_changes = false;
|
||||||
|
|
||||||
|
if let Some(ref candidate) = title {
|
||||||
|
let trimmed = candidate.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppError::bad_request("title must not be empty"));
|
||||||
|
}
|
||||||
|
if trimmed != document.title {
|
||||||
|
let new_title = trimmed.to_string();
|
||||||
|
let new_filename = filename_with_retained_extension(&new_title, &document.filename);
|
||||||
|
changes.title = Some(new_title);
|
||||||
|
if new_filename != document.filename {
|
||||||
|
changes.filename = Some(new_filename);
|
||||||
|
}
|
||||||
|
has_changes = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match issued_at_class {
|
||||||
|
NullableValue::Omitted => {}
|
||||||
|
NullableValue::Null => {
|
||||||
|
if document.issued_at.is_some() {
|
||||||
|
changes.issued_at = Some(None);
|
||||||
|
has_changes = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NullableValue::String(raw) => {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppError::bad_request("issued_at must not be empty"));
|
||||||
|
}
|
||||||
|
let parsed = DateTime::parse_from_rfc3339(trimmed).map_err(|err| {
|
||||||
|
let msg = format!("issued_at must be an RFC3339 timestamp: {err}");
|
||||||
|
AppError::bad_request(msg)
|
||||||
|
})?;
|
||||||
|
let normalized = Some(parsed.naive_utc());
|
||||||
|
if document.issued_at != normalized {
|
||||||
|
changes.issued_at = Some(normalized);
|
||||||
|
has_changes = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(metadata_update) = metadata {
|
||||||
|
let next_metadata = if metadata_update.replace {
|
||||||
|
metadata_update.value
|
||||||
|
} else {
|
||||||
|
merge_document_metadata(document.metadata.clone(), metadata_update.value)?
|
||||||
|
};
|
||||||
|
|
||||||
|
if document.metadata != next_metadata {
|
||||||
|
changes.metadata = Some(next_metadata);
|
||||||
|
has_changes = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_changes {
|
||||||
return Err(AppError::bad_request("no changes provided"));
|
return Err(AppError::bad_request("no changes provided"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(title) = new_title {
|
let now = Utc::now().naive_utc();
|
||||||
let now = Utc::now().naive_utc();
|
changes.updated_at = Some(now);
|
||||||
let new_filename = filename_with_retained_extension(&title, &document.filename);
|
|
||||||
|
|
||||||
let target = documents::table
|
let target = documents::table
|
||||||
.find(document_id)
|
.find(document_id)
|
||||||
.filter(documents::tenant_id.eq(tenant_id));
|
.filter(documents::tenant_id.eq(tenant_id));
|
||||||
|
|
||||||
let update_result = diesel::update(target).set((
|
let update_result = diesel::update(target).set(&changes);
|
||||||
documents::title.eq(&title),
|
|
||||||
documents::filename.eq(&new_filename),
|
|
||||||
documents::updated_at.eq(now),
|
|
||||||
));
|
|
||||||
|
|
||||||
match update_result.execute(&mut conn) {
|
match update_result.execute(&mut conn) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
|
Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
|
||||||
return Err(AppError::conflict(
|
return Err(AppError::conflict(
|
||||||
"another document in this folder already uses that filename",
|
"another document in this folder already uses that filename",
|
||||||
)
|
)
|
||||||
.with_code("duplicate_filename"));
|
.with_code("duplicate_filename"));
|
||||||
}
|
|
||||||
Err(err) => return Err(AppError::from(err)),
|
|
||||||
}
|
}
|
||||||
|
Err(err) => return Err(AppError::from(err)),
|
||||||
document = documents::table
|
|
||||||
.find(document_id)
|
|
||||||
.filter(documents::tenant_id.eq(tenant_id))
|
|
||||||
.first(&mut conn)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document = documents::table
|
||||||
|
.find(document_id)
|
||||||
|
.filter(documents::tenant_id.eq(tenant_id))
|
||||||
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let current_version: DocumentVersion = document_versions::table
|
let current_version: DocumentVersion = document_versions::table
|
||||||
.find(document.current_version_id)
|
.find(document.current_version_id)
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
@@ -1300,6 +1387,55 @@ pub async fn update_document(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
|
||||||
|
let mut base = match existing {
|
||||||
|
Value::Object(map) => map,
|
||||||
|
Value::Null => Map::new(),
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"existing metadata is not an object; set replace=true to overwrite",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let incoming = match updates {
|
||||||
|
Value::Object(map) => map,
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"metadata value must be a JSON object when replace is false",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
merge_metadata_maps(&mut base, incoming);
|
||||||
|
Ok(Value::Object(base))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_metadata_maps(target: &mut Map<String, Value>, updates: Map<String, Value>) {
|
||||||
|
for (key, value) in updates {
|
||||||
|
match target.entry(key) {
|
||||||
|
Entry::Occupied(mut entry) => {
|
||||||
|
let existing = entry.get_mut();
|
||||||
|
match value {
|
||||||
|
Value::Object(update_map) => {
|
||||||
|
if let Value::Object(existing_map) = existing {
|
||||||
|
merge_metadata_maps(existing_map, update_map);
|
||||||
|
} else {
|
||||||
|
*existing = Value::Object(update_map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
*existing = other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Entry::Vacant(entry) => {
|
||||||
|
entry.insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn move_document(
|
pub async fn move_document(
|
||||||
Path(document_id): Path<Uuid>,
|
Path(document_id): Path<Uuid>,
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use anyhow::Result;
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras};
|
use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -19,6 +20,7 @@ struct DocumentInfo {
|
|||||||
original_name: String,
|
original_name: String,
|
||||||
deleted_at: Option<String>,
|
deleted_at: Option<String>,
|
||||||
issued_at: Option<String>,
|
issued_at: Option<String>,
|
||||||
|
metadata: Value,
|
||||||
tags: Vec<TagSummary>,
|
tags: Vec<TagSummary>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
correspondents: Vec<DocumentCorrespondentInfo>,
|
correspondents: Vec<DocumentCorrespondentInfo>,
|
||||||
@@ -103,6 +105,13 @@ struct AnalyzeJobPayload {
|
|||||||
force: bool,
|
force: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ErrorResponse {
|
||||||
|
error: String,
|
||||||
|
#[serde(default)]
|
||||||
|
code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct FolderResponse {
|
struct FolderResponse {
|
||||||
folder: FolderInfo,
|
folder: FolderInfo,
|
||||||
@@ -1229,3 +1238,397 @@ async fn bulk_reanalyze_selected_documents() -> Result<()> {
|
|||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user