8cfcf07387
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>
306 lines
8.5 KiB
Rust
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);
|
|
}
|