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>
This commit is contained in:
@@ -370,10 +370,11 @@ pub async fn set_visibility(
|
|||||||
|
|
||||||
let new_visibility = object.visibility.transition_to(target)?;
|
let new_visibility = object.visibility.transition_to(target)?;
|
||||||
|
|
||||||
// The publish gate: a record may only become public once every required field
|
// The publish gate: a record may only *become* public once every required field
|
||||||
// has a value. The typed inventory-minimum columns are already NOT NULL, so only
|
// has a value. The typed inventory-minimum columns are already NOT NULL, so only
|
||||||
// the flexible required fields need checking here.
|
// the flexible required fields need checking here. Gated on an actual transition
|
||||||
if new_visibility == Visibility::Public {
|
// into public so a set-to-current no-op stays a no-op (never a late rejection).
|
||||||
|
if new_visibility == Visibility::Public && object.visibility != Visibility::Public {
|
||||||
let missing = missing_required_fields(&mut *conn, &object.fields).await?;
|
let missing = missing_required_fields(&mut *conn, &object.fields).await?;
|
||||||
|
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
|
|||||||
@@ -256,3 +256,50 @@ async fn publishing_requires_all_required_fields_present(pool: PgPool) {
|
|||||||
let published = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
let published = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||||
assert_eq!(published.visibility, Visibility::Public);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user