Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cfcf07387 | |||
| e96f74f47a |
@@ -166,6 +166,9 @@ pub(crate) async fn set_visibility(
|
|||||||
}
|
}
|
||||||
Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
|
Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
|
||||||
Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT),
|
Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT),
|
||||||
|
Err(db::catalog::VisibilityError::MissingRequiredFields(_)) => {
|
||||||
|
Err(StatusCode::UNPROCESSABLE_ENTITY)
|
||||||
|
}
|
||||||
Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,3 +335,80 @@ async fn illegal_visibility_transition_is_409(pool: PgPool) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(publish.status(), StatusCode::CONFLICT);
|
assert_eq!(publish.status(), StatusCode::CONFLICT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn publishing_without_required_field_is_422(pool: PgPool) {
|
||||||
|
use domain::{FieldType, LocalizedLabel, NewFieldDefinition};
|
||||||
|
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let db = db::Db::from_pool(pool.clone());
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
db::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,
|
||||||
|
&ObjectInput {
|
||||||
|
object_number: "P-2".into(),
|
||||||
|
object_name: "vase".into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: None,
|
||||||
|
current_location: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility: Visibility::Internal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool.clone()));
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(login_request("editor@example.com", "pw-editor-123"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let cookie = session_cookie(&resp);
|
||||||
|
|
||||||
|
// publishing while a required field has no value -> 422, visibility unchanged
|
||||||
|
let publish = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/admin/objects/{id}/visibility"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"visibility":"public"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(publish.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(obj.visibility, Visibility::Internal);
|
||||||
|
}
|
||||||
|
|||||||
@@ -348,6 +348,8 @@ pub enum VisibilityError {
|
|||||||
ObjectNotFound,
|
ObjectNotFound,
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Illegal(#[from] IllegalTransition),
|
Illegal(#[from] IllegalTransition),
|
||||||
|
#[error("missing required field(s): {}", .0.join(", "))]
|
||||||
|
MissingRequiredFields(Vec<String>),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Db(#[from] sqlx::Error),
|
Db(#[from] sqlx::Error),
|
||||||
}
|
}
|
||||||
@@ -368,6 +370,18 @@ 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
|
||||||
|
// has a value. The typed inventory-minimum columns are already NOT NULL, so only
|
||||||
|
// the flexible required fields need checking here. Gated on an actual transition
|
||||||
|
// 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?;
|
||||||
|
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return Err(VisibilityError::MissingRequiredFields(missing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let old_input = object.to_input();
|
let old_input = object.to_input();
|
||||||
let mut new_input = old_input.clone();
|
let mut new_input = old_input.clone();
|
||||||
|
|
||||||
@@ -377,6 +391,22 @@ pub async fn set_visibility(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The keys of `required` field definitions that have no value on `fields` (absent or
|
||||||
|
/// null). Empty when every required field is present.
|
||||||
|
async fn missing_required_fields(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
fields: &Value,
|
||||||
|
) -> Result<Vec<String>, sqlx::Error> {
|
||||||
|
let definitions = fields::list_field_definitions(&mut *conn).await?;
|
||||||
|
|
||||||
|
Ok(definitions
|
||||||
|
.into_iter()
|
||||||
|
.filter(|definition| definition.required)
|
||||||
|
.filter(|definition| fields.get(&definition.key).is_none_or(Value::is_null))
|
||||||
|
.map(|definition| definition.key)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete an object and record a `deleted` audit entry, both on `conn`.
|
/// Delete an object and record a `deleted` audit entry, both on `conn`.
|
||||||
/// Returns `false` if the object did not exist.
|
/// Returns `false` if the object did not exist.
|
||||||
pub async fn delete_object(
|
pub async fn delete_object(
|
||||||
|
|||||||
@@ -184,3 +184,122 @@ async fn public_reads_return_only_public_records(pool: PgPool) {
|
|||||||
.is_none()
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user