984be697ac
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
384 lines
11 KiB
Rust
384 lines
11 KiB
Rust
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 mut tx = db.pool().begin().await.unwrap();
|
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
|
.await
|
|
.unwrap();
|
|
tx.commit().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,
|
|
AuditActor::System,
|
|
&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 { .. })
|
|
));
|
|
drop(tx);
|
|
}
|
|
|
|
#[sqlx::test]
|
|
async fn authority_field_enforces_kind(pool: PgPool) {
|
|
let db = Db::from_pool(pool);
|
|
let id = setup_object(&db).await;
|
|
define(
|
|
&db,
|
|
"maker",
|
|
FieldType::Authority {
|
|
kind: Some(domain::AuthorityKind::Person),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let mut tx = db.pool().begin().await.unwrap();
|
|
let person = db::authority::create_authority(
|
|
&mut tx,
|
|
AuditActor::System,
|
|
&domain::NewAuthority {
|
|
kind: domain::AuthorityKind::Person,
|
|
external_uri: None,
|
|
labels: label("Carl"),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let place = db::authority::create_authority(
|
|
&mut tx,
|
|
AuditActor::System,
|
|
&domain::NewAuthority {
|
|
kind: domain::AuthorityKind::Place,
|
|
external_uri: None,
|
|
labels: label("Stockholm"),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tx.commit().await.unwrap();
|
|
|
|
let ok = serde_json::json!({ "maker": person.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!({ "maker": place.to_string() });
|
|
let mut tx = db.pool().begin().await.unwrap();
|
|
assert!(matches!(
|
|
catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await,
|
|
Err(FieldError::Unresolved { .. })
|
|
));
|
|
}
|
|
|
|
#[sqlx::test]
|
|
async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
|
|
let db = Db::from_pool(pool);
|
|
let id = setup_object(&db).await;
|
|
let mut tx = db.pool().begin().await.unwrap();
|
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
|
.await
|
|
.unwrap();
|
|
let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique")
|
|
.await
|
|
.unwrap();
|
|
tx.commit().await.unwrap();
|
|
define(
|
|
&db,
|
|
"material",
|
|
FieldType::Term {
|
|
vocabulary_id: material.id,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
// a real term, but in the WRONG vocabulary
|
|
let mut tx = db.pool().begin().await.unwrap();
|
|
let other = vocab::add_term(
|
|
&mut tx,
|
|
AuditActor::System,
|
|
&domain::NewTerm {
|
|
vocabulary_id: technique.id,
|
|
external_uri: None,
|
|
labels: label("forged"),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tx.commit().await.unwrap();
|
|
|
|
let bad = serde_json::json!({ "material": other.to_string() });
|
|
let mut tx = db.pool().begin().await.unwrap();
|
|
assert!(matches!(
|
|
catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await,
|
|
Err(FieldError::Unresolved { .. })
|
|
));
|
|
}
|
|
|
|
#[sqlx::test]
|
|
async fn localized_text_round_trips(pool: PgPool) {
|
|
let db = Db::from_pool(pool);
|
|
let id = setup_object(&db).await;
|
|
define(&db, "title", FieldType::LocalizedText).await;
|
|
|
|
let values = serde_json::json!({ "title": { "sv": "Vas", "en": "Vase" } });
|
|
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["title"]["sv"], "Vas");
|
|
assert_eq!(obj.fields["title"]["en"], "Vase");
|
|
|
|
let bad = serde_json::json!({ "title": { "sv": 5 } });
|
|
let mut tx = db.pool().begin().await.unwrap();
|
|
assert!(matches!(
|
|
catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await,
|
|
Err(FieldError::TypeMismatch { .. })
|
|
));
|
|
}
|
|
|
|
#[sqlx::test]
|
|
async fn replace_semantics_remove_a_field_and_audit_it(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;
|
|
|
|
let mut tx = db.pool().begin().await.unwrap();
|
|
catalog::set_object_fields(
|
|
&mut tx,
|
|
AuditActor::System,
|
|
id,
|
|
serde_json::json!({ "comments": "x", "year": 1850 })
|
|
.as_object()
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tx.commit().await.unwrap();
|
|
|
|
let mut tx = db.pool().begin().await.unwrap();
|
|
catalog::set_object_fields(
|
|
&mut tx,
|
|
AuditActor::System,
|
|
id,
|
|
serde_json::json!({ "comments": "x" }).as_object().unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tx.commit().await.unwrap();
|
|
|
|
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
|
assert!(obj.fields.get("year").is_none());
|
|
|
|
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
|
.await
|
|
.unwrap();
|
|
let last = history.last().unwrap();
|
|
let year = last
|
|
.changes
|
|
.iter()
|
|
.find(|c| c.field == "year")
|
|
.expect("year removal recorded");
|
|
assert!(year.before.is_some());
|
|
assert!(year.after.is_none());
|
|
}
|
|
|
|
#[sqlx::test]
|
|
async fn no_op_set_records_no_audit(pool: PgPool) {
|
|
let db = Db::from_pool(pool);
|
|
let id = setup_object(&db).await;
|
|
define(&db, "comments", FieldType::Text).await;
|
|
|
|
let values = serde_json::json!({ "comments": "x" });
|
|
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 before = audit::history_for(db.pool(), "object", id.to_uuid())
|
|
.await
|
|
.unwrap()
|
|
.len();
|
|
|
|
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 after = audit::history_for(db.pool(), "object", id.to_uuid())
|
|
.await
|
|
.unwrap()
|
|
.len();
|
|
assert_eq!(before, after, "a no-op set must not add an audit entry");
|
|
}
|
|
|
|
#[sqlx::test]
|
|
async fn set_on_missing_object_errors(pool: PgPool) {
|
|
let db = Db::from_pool(pool);
|
|
let mut tx = db.pool().begin().await.unwrap();
|
|
let err = catalog::set_object_fields(
|
|
&mut tx,
|
|
AuditActor::System,
|
|
domain::ObjectId::new(),
|
|
serde_json::json!({}).as_object().unwrap(),
|
|
)
|
|
.await;
|
|
assert!(matches!(err, Err(FieldError::ObjectNotFound)));
|
|
}
|