feat(db): add catalogue object update/delete with audited field diffs

update_object records only changed fields as audit diffs and skips the
audit entry for no-op updates; delete_object records a Deleted entry.
Both operations are atomic on the caller's connection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 09:32:16 +02:00
parent 616a6f05c6
commit 9e1c88b294
2 changed files with 206 additions and 2 deletions
+123
View File
@@ -0,0 +1,123 @@
use db::{Db, audit, catalog};
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
use sqlx::PgPool;
fn base() -> ObjectInput {
ObjectInput {
object_number: "LM-1".into(),
object_name: "vase".into(),
number_of_objects: 1,
brief_description: None,
current_location: Some("shelf A1".into()),
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Draft,
}
}
#[sqlx::test]
async fn update_changes_are_audited_as_diffs(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
.await
.unwrap();
tx.commit().await.unwrap();
let mut changed = base();
changed.object_name = "roman vase".into();
changed.visibility = Visibility::Public;
let mut tx = db.pool().begin().await.unwrap();
let updated = catalog::update_object(&mut tx, AuditActor::System, id, &changed)
.await
.unwrap();
tx.commit().await.unwrap();
assert!(updated);
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(obj.object_name, "roman vase");
assert_eq!(obj.visibility, Visibility::Public);
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
assert_eq!(history.len(), 2); // created + updated
let update = &history[1];
assert_eq!(update.action, AuditAction::Updated);
let mut fields: Vec<&str> = update.changes.iter().map(|c| c.field.as_str()).collect();
fields.sort_unstable();
assert_eq!(fields, vec!["object_name", "visibility"]);
}
#[sqlx::test]
async fn no_op_update_records_no_audit(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
.await
.unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let updated = catalog::update_object(&mut tx, AuditActor::System, id, &base())
.await
.unwrap();
tx.commit().await.unwrap();
assert!(updated);
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
assert_eq!(
history.len(),
1,
"a no-op update must not add an audit entry"
);
}
#[sqlx::test]
async fn update_missing_returns_false(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let updated = catalog::update_object(
&mut tx,
AuditActor::System,
domain::ObjectId::new(),
&base(),
)
.await
.unwrap();
tx.commit().await.unwrap();
assert!(!updated);
}
#[sqlx::test]
async fn delete_removes_and_audits(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
.await
.unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let deleted = catalog::delete_object(&mut tx, AuditActor::System, id)
.await
.unwrap();
tx.commit().await.unwrap();
assert!(deleted);
assert!(
catalog::object_by_id(db.pool(), id)
.await
.unwrap()
.is_none()
);
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
assert_eq!(history.last().unwrap().action, AuditAction::Deleted);
}