feat(db): enforce required-field completeness on publish (#16)
set_visibility now gates the transition to Public: every field definition with required=true must have a value on the object (typed inventory-minimum columns are already NOT NULL, so only flexible required fields are checked). Missing values yield VisibilityError::MissingRequiredFields(keys); the admin publish endpoint maps it to 422. The gate runs in db so every caller is protected and the check is atomic with the transition. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -166,6 +166,9 @@ pub(crate) async fn set_visibility(
|
||||
}
|
||||
Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,3 +335,80 @@ async fn illegal_visibility_transition_is_409(pool: PgPool) {
|
||||
.unwrap();
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user