diff --git a/crates/db/src/catalog.rs b/crates/db/src/catalog.rs index d324657..f8a6efe 100644 --- a/crates/db/src/catalog.rs +++ b/crates/db/src/catalog.rs @@ -157,8 +157,6 @@ fn creation_changes(input: &ObjectInput) -> Vec { } /// Audit changes between two field sets: only the fields whose value changed. -/// (Used by `update_object` in the next task.) -#[allow(dead_code)] fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec { field_values(old) .into_iter() @@ -176,3 +174,86 @@ fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec { }) .collect() } + +/// Update an object and record an `updated` audit entry with field-level diffs, +/// both on `conn`. Returns `false` if the object does not exist. A no-op update +/// (no fields changed) records no audit entry. +pub async fn update_object( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: ObjectId, + input: &ObjectInput, +) -> Result { + let Some(old) = object_by_id(&mut *conn, id).await? else { + return Ok(false); + }; + + sqlx::query( + "UPDATE object SET \ + object_number = $2, object_name = $3, number_of_objects = $4, \ + brief_description = $5, current_location = $6, current_owner = $7, \ + recorder = $8, recording_date = $9, visibility = $10, updated_at = now() \ + WHERE id = $1", + ) + .bind(id.to_uuid()) + .bind(&input.object_number) + .bind(&input.object_name) + .bind(input.number_of_objects) + .bind(input.brief_description.as_deref()) + .bind(input.current_location.as_deref()) + .bind(input.current_owner.as_deref()) + .bind(input.recorder.as_deref()) + .bind(input.recording_date) + .bind(input.visibility.as_str()) + .execute(&mut *conn) + .await?; + + let changes = update_changes(&old.to_input(), input); + + if !changes.is_empty() { + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Updated, + entity_type: ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), + changes, + }, + ) + .await?; + } + + Ok(true) +} + +/// Delete an object and record a `deleted` audit entry, both on `conn`. +/// Returns `false` if the object did not exist. +pub async fn delete_object( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: ObjectId, +) -> Result { + if object_by_id(&mut *conn, id).await?.is_none() { + return Ok(false); + } + + sqlx::query("DELETE FROM object WHERE id = $1") + .bind(id.to_uuid()) + .execute(&mut *conn) + .await?; + + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Deleted, + entity_type: ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), + changes: Vec::new(), + }, + ) + .await?; + + Ok(true) +} diff --git a/crates/db/tests/catalog_mutations.rs b/crates/db/tests/catalog_mutations.rs new file mode 100644 index 0000000..59f8a10 --- /dev/null +++ b/crates/db/tests/catalog_mutations.rs @@ -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); +}