Files
logaritmisk 8cfcf07387 fix(db): publish gate fires only on transition into public, not re-set
Preserves the documented set-to-current idempotent no-op: re-setting an
already-public object's visibility no longer rejects when a required field
was introduced after publish. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:10 +02:00

306 lines
8.5 KiB
Rust

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();
let internal = catalog::create_object(
&mut tx,
AuditActor::System,
&object("I-1", Visibility::Internal),
)
.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()
);
// internal records are excluded from public reads too (not just draft)
assert!(
catalog::public_object_by_id(db.pool(), internal)
.await
.unwrap()
.is_none()
);
}
#[sqlx::test]
async fn publishing_requires_all_required_fields_present(pool: PgPool) {
use db::fields;
use domain::{FieldType, LocalizedLabel, NewFieldDefinition};
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
// a required flexible field
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "inscription".into(),
field_type: FieldType::Text,
required: true,
group_key: None,
labels: vec![LocalizedLabel {
lang: "en".into(),
label: "Inscription".into(),
}],
},
)
.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();
// publishing without the required field present is rejected
let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
.await
.unwrap_err();
assert!(
matches!(err, catalog::VisibilityError::MissingRequiredFields(ref keys) if keys == &["inscription"])
);
// the object is still not public
let still = catalog::object_by_id(&mut *tx, id).await.unwrap().unwrap();
assert_eq!(still.visibility, Visibility::Internal);
// set the required field, then publishing succeeds
catalog::set_object_fields(
&mut tx,
AuditActor::System,
id,
serde_json::json!({ "inscription": "To the gods" })
.as_object()
.unwrap(),
)
.await
.unwrap();
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
.await
.unwrap();
tx.commit().await.unwrap();
let published = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(published.visibility, Visibility::Public);
}
#[sqlx::test]
async fn republishing_a_public_object_is_a_noop_even_with_a_new_required_field(pool: PgPool) {
use db::fields;
use domain::{FieldType, LocalizedLabel, NewFieldDefinition};
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
// an already-public object (created public directly at the db layer)
let id = catalog::create_object(
&mut tx,
AuditActor::System,
&object("LM-2", Visibility::Public),
)
.await
.unwrap();
// a required field is introduced AFTER the object is already public
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "inscription".into(),
field_type: FieldType::Text,
required: true,
group_key: None,
labels: vec![LocalizedLabel {
lang: "en".into(),
label: "Inscription".into(),
}],
},
)
.await
.unwrap();
// setting visibility to its current value stays an idempotent no-op — the publish
// gate only fires on an actual transition into public, not on a re-set.
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);
}