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 { 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 { .. }) )); 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, &domain::NewAuthority { kind: domain::AuthorityKind::Person, external_uri: None, labels: label("Carl"), }, ) .await .unwrap(); let place = db::authority::create_authority( &mut tx, &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 material = vocab::create_vocabulary(db.pool(), "material") .await .unwrap(); let technique = vocab::create_vocabulary(db.pool(), "technique") .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, &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))); }