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); }