feat(db): set_object_fields with registry validation and audited diffs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 10:59:23 +02:00
parent 2aaf98794f
commit 2b0056c038
2 changed files with 343 additions and 3 deletions
+164
View File
@@ -0,0 +1,164 @@
use db::catalog::FieldError;
use db::{Db, audit, catalog, fields, vocab};
use domain::{
AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, ObjectInput, Visibility,
};
use sqlx::PgPool;
fn obj_input() -> ObjectInput {
ObjectInput {
object_number: "LM-1".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::Draft,
}
}
fn label(text: &str) -> Vec<LocalizedLabel> {
vec![LocalizedLabel {
lang: "en".into(),
label: text.into(),
}]
}
async fn setup_object(db: &Db) -> domain::ObjectId {
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &obj_input())
.await
.unwrap();
tx.commit().await.unwrap();
id
}
async fn define(db: &Db, key: &str, field_type: FieldType) {
let mut tx = db.pool().begin().await.unwrap();
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: key.into(),
field_type,
required: false,
group_key: None,
labels: label(key),
},
)
.await
.unwrap();
tx.commit().await.unwrap();
}
#[sqlx::test]
async fn sets_scalar_fields_and_audits(pool: PgPool) {
let db = Db::from_pool(pool);
let id = setup_object(&db).await;
define(&db, "comments", FieldType::Text).await;
define(&db, "year", FieldType::Integer).await;
define(&db, "on_display", FieldType::Boolean).await;
let values = serde_json::json!({ "comments": "nice", "year": 1850, "on_display": true });
let mut tx = db.pool().begin().await.unwrap();
catalog::set_object_fields(&mut tx, AuditActor::System, id, values.as_object().unwrap())
.await
.unwrap();
tx.commit().await.unwrap();
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(obj.fields["comments"], "nice");
assert_eq!(obj.fields["year"], 1850);
assert_eq!(obj.fields["on_display"], true);
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
assert_eq!(history.last().unwrap().action, AuditAction::Updated);
let changed: Vec<&str> = history
.last()
.unwrap()
.changes
.iter()
.map(|c| c.field.as_str())
.collect();
assert!(
changed.contains(&"comments")
&& changed.contains(&"year")
&& changed.contains(&"on_display")
);
}
#[sqlx::test]
async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
let db = Db::from_pool(pool);
let id = setup_object(&db).await;
let material = vocab::create_vocabulary(db.pool(), "material")
.await
.unwrap();
define(
&db,
"material",
FieldType::Term {
vocabulary_id: material.id,
},
)
.await;
let mut tx = db.pool().begin().await.unwrap();
let wood = vocab::add_term(
&mut tx,
&domain::NewTerm {
vocabulary_id: material.id,
external_uri: None,
labels: label("wood"),
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let ok = serde_json::json!({ "material": wood.to_string() });
let mut tx = db.pool().begin().await.unwrap();
catalog::set_object_fields(&mut tx, AuditActor::System, id, ok.as_object().unwrap())
.await
.unwrap();
tx.commit().await.unwrap();
let bad = serde_json::json!({ "material": domain::TermId::new().to_string() });
let mut tx = db.pool().begin().await.unwrap();
let err =
catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await;
assert!(matches!(err, Err(FieldError::Unresolved { .. })));
}
#[sqlx::test]
async fn unknown_field_and_type_mismatch_are_rejected(pool: PgPool) {
let db = Db::from_pool(pool);
let id = setup_object(&db).await;
define(&db, "year", FieldType::Integer).await;
let unknown = serde_json::json!({ "nope": "x" });
let mut tx = db.pool().begin().await.unwrap();
assert!(matches!(
catalog::set_object_fields(
&mut tx,
AuditActor::System,
id,
unknown.as_object().unwrap()
)
.await,
Err(FieldError::UnknownField(_))
));
drop(tx);
let wrong = serde_json::json!({ "year": "not a number" });
let mut tx = db.pool().begin().await.unwrap();
assert!(matches!(
catalog::set_object_fields(&mut tx, AuditActor::System, id, wrong.as_object().unwrap())
.await,
Err(FieldError::TypeMismatch { .. })
));
}