feat(db): audited stepwise set_visibility + public-only object readers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
use db::{Db, audit, catalog};
|
||||
use domain::{AuditAction, AuditActor, IllegalTransition, ObjectInput, Visibility};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn object(number: &str, visibility: Visibility) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn publish_steps_through_internal_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,
|
||||
&object("LM-1", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Internal)
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.visibility, Visibility::Public);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 3); // created + two visibility updates
|
||||
assert_eq!(history[2].action, AuditAction::Updated);
|
||||
let changed: Vec<&str> = history[2]
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| c.field.as_str())
|
||||
.collect();
|
||||
assert_eq!(changed, vec!["visibility"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn skipping_a_step_is_rejected_and_unchanged(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,
|
||||
&object("LM-1", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||
.await
|
||||
.unwrap_err();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(matches!(
|
||||
err,
|
||||
catalog::VisibilityError::Illegal(IllegalTransition {
|
||||
from: Visibility::Draft,
|
||||
to: Visibility::Public
|
||||
})
|
||||
));
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.visibility, Visibility::Draft); // unchanged
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn set_visibility_on_missing_object_errors(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err = catalog::set_visibility(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
domain::ObjectId::new(),
|
||||
Visibility::Internal,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(matches!(err, catalog::VisibilityError::ObjectNotFound));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn no_op_set_to_current_visibility_writes_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,
|
||||
&object("LM-1", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Draft)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 1); // only `created`; the no-op transition recorded nothing
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn public_reads_return_only_public_records(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let draft = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("D-1", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let pub_id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("P-1", Visibility::Public),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
assert!(
|
||||
catalog::public_object_by_id(db.pool(), pub_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
catalog::public_object_by_id(db.pool(), draft)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let listed = catalog::list_public_objects(db.pool(), 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, pub_id);
|
||||
assert_eq!(catalog::count_public_objects(db.pool()).await.unwrap(), 1);
|
||||
|
||||
assert!(
|
||||
catalog::list_public_objects(db.pool(), 50, 1)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user