use auditlog::{
Action, Actor, AuditError, AuditId, AuditOptions, Auditable, SqlxBackend, UndoPlan, ValueMap,
as_user, with_auditing, without_auditing,
};
use serde_json::{Value, json};
async fn backend() -> SqlxBackend {
let b = SqlxBackend::connect_sqlite("sqlite::memory:")
.await
.unwrap();
b.migrate().await.unwrap();
b
}
fn build_attrs(
id: i64,
name: &Option<String>,
status: i64,
logins: i64,
password: &Option<String>,
) -> ValueMap {
let mut m = ValueMap::new();
m.insert("id".into(), json!(id));
m.insert(
"name".into(),
name.clone().map(Value::from).unwrap_or(Value::Null),
);
m.insert("status".into(), json!(status));
m.insert("logins".into(), json!(logins));
m.insert(
"password".into(),
password.clone().map(Value::from).unwrap_or(Value::Null),
);
m
}
macro_rules! model {
($name:ident, $type_name:expr, $opts:expr) => {
#[derive(Clone, Default)]
#[allow(dead_code)]
struct $name {
id: i64,
name: Option<String>,
status: i64,
logins: i64,
password: Option<String>,
}
impl Auditable for $name {
fn auditable_type() -> &'static str {
$type_name
}
fn auditable_id(&self) -> AuditId {
self.id.into()
}
fn audited_attributes(&self) -> ValueMap {
build_attrs(
self.id,
&self.name,
self.status,
self.logins,
&self.password,
)
}
fn audit_options() -> AuditOptions {
$opts
}
}
#[allow(dead_code)]
impl $name {
fn named(id: i64, name: &str) -> Self {
$name {
id,
name: Some(name.to_string()),
..Default::default()
}
}
}
};
}
model!(User, "User", AuditOptions::default());
model!(
OnlyName,
"OnlyName",
AuditOptions::builder().only(["name"]).build()
);
model!(
ExceptPassword,
"ExceptPassword",
AuditOptions::builder().except(["password"]).build()
);
model!(
Commented,
"Commented",
AuditOptions::builder().comment_required(true).build()
);
model!(
NoUpdateOnly,
"NoUpdateOnly",
AuditOptions::builder()
.update_with_comment_only(false)
.build()
);
model!(
Capped,
"Capped",
AuditOptions::builder().max_audits(2).build()
);
model!(
Redacted,
"Redacted",
AuditOptions::builder().redacted(["password"]).build()
);
model!(
RedactedCustom,
"RedactedCustom",
AuditOptions::builder()
.redacted(["password"])
.redaction_value(json!("***"))
.build()
);
model!(
Encrypted,
"Encrypted",
AuditOptions::builder().encrypted(["password"]).build()
);
model!(
OnlyCreate,
"OnlyCreate",
AuditOptions::builder().on([Action::Create]).build()
);
model!(
OnlyUpdate,
"OnlyUpdate",
AuditOptions::builder().on([Action::Update]).build()
);
model!(Disablable, "Disablable", AuditOptions::default());
model!(
Collapse,
"Collapse",
AuditOptions::builder().max_audits(0).build()
);
#[tokio::test]
async fn create_stores_single_values_snapshot() {
let b = backend().await;
let u = User::named(1, "Brandon");
let audit = u.audited_create(&b).await.unwrap().unwrap();
assert_eq!(audit.action, Action::Create);
assert_eq!(audit.version, 1);
assert_eq!(audit.audited_changes.0.get("name"), Some(&json!("Brandon")));
assert!(!audit.audited_changes.0.contains_key("id"));
let na = audit.new_attributes();
assert_eq!(na.get("name"), Some(&json!("Brandon")));
}
#[tokio::test]
async fn update_stores_old_new_pairs() {
let b = backend().await;
let old = User::named(1, "Brandon");
old.audited_create(&b).await.unwrap();
let new = User::named(1, "Changed");
let audit = new.audited_update(&b, &old).await.unwrap().unwrap();
assert_eq!(audit.action, Action::Update);
assert_eq!(audit.version, 2);
assert_eq!(
audit.audited_changes.0.get("name"),
Some(&json!(["Brandon", "Changed"]))
);
assert_eq!(audit.new_attributes().get("name"), Some(&json!("Changed")));
assert_eq!(audit.old_attributes().get("name"), Some(&json!("Brandon")));
}
#[tokio::test]
async fn destroy_stores_full_snapshot_like_create() {
let b = backend().await;
let u = User::named(1, "Brandon");
u.audited_create(&b).await.unwrap();
let audit = u.audited_destroy(&b).await.unwrap().unwrap();
assert_eq!(audit.action, Action::Destroy);
assert_eq!(audit.version, 2);
assert_eq!(audit.audited_changes.0.get("name"), Some(&json!("Brandon")));
assert_eq!(audit.audited_changes.0.get("status"), Some(&json!(0)));
}
#[tokio::test]
async fn unchanged_update_writes_no_audit() {
let b = backend().await;
let old = User::named(1, "Brandon");
old.audited_create(&b).await.unwrap();
let same = User::named(1, "Brandon");
assert!(same.audited_update(&b, &old).await.unwrap().is_none());
assert_eq!(User::audits(&b, 1).await.unwrap().len(), 1);
}
#[tokio::test]
async fn version_increments_across_action_types() {
let b = backend().await;
let mut u = User::named(1, "A");
u.audited_create(&b).await.unwrap();
let old = u.clone();
u.name = Some("B".into());
u.audited_update(&b, &old).await.unwrap();
u.audited_destroy(&b).await.unwrap();
let audits = User::audits(&b, 1).await.unwrap();
let versions: Vec<i32> = audits.iter().map(|a| a.version).collect();
assert_eq!(versions, vec![1, 2, 3]);
let actions: Vec<Action> = audits.iter().map(|a| a.action).collect();
assert_eq!(
actions,
vec![Action::Create, Action::Update, Action::Destroy]
);
}
#[tokio::test]
async fn on_create_only_skips_updates_and_destroys() {
let b = backend().await;
let mut u = OnlyCreate {
id: 1,
name: Some("A".into()),
..Default::default()
};
assert!(u.audited_create(&b).await.unwrap().is_some());
let old = u.clone();
u.name = Some("B".into());
assert!(u.audited_update(&b, &old).await.unwrap().is_none());
assert!(u.audited_destroy(&b).await.unwrap().is_none());
assert_eq!(OnlyCreate::audits(&b, 1).await.unwrap().len(), 1);
}
#[tokio::test]
async fn on_update_only_skips_creates() {
let b = backend().await;
let old = OnlyUpdate {
id: 1,
name: Some("A".into()),
..Default::default()
};
assert!(old.audited_create(&b).await.unwrap().is_none());
let new = OnlyUpdate {
id: 1,
name: Some("B".into()),
..Default::default()
};
assert!(new.audited_update(&b, &old).await.unwrap().is_some());
let audits = OnlyUpdate::audits(&b, 1).await.unwrap();
assert_eq!(audits.len(), 1);
assert_eq!(audits[0].action, Action::Update);
}
#[tokio::test]
async fn only_audits_listed_columns() {
let b = backend().await;
let u = OnlyName {
id: 1,
name: Some("A".into()),
status: 5,
logins: 9,
password: Some("secret".into()),
};
let audit = u.audited_create(&b).await.unwrap().unwrap();
let keys: Vec<&String> = audit.audited_changes.0.keys().collect();
assert_eq!(keys, vec![&"name".to_string()]);
}
#[tokio::test]
async fn except_excludes_listed_columns_on_create_and_destroy() {
let b = backend().await;
let u = ExceptPassword {
id: 1,
name: Some("A".into()),
password: Some("secret".into()),
..Default::default()
};
let audit = u.audited_create(&b).await.unwrap().unwrap();
assert!(!audit.audited_changes.0.contains_key("password"));
assert!(audit.audited_changes.0.contains_key("name"));
let d = u.audited_destroy(&b).await.unwrap().unwrap();
assert!(!d.audited_changes.0.contains_key("password"));
}
#[tokio::test]
async fn redacted_create_is_single_placeholder() {
let b = backend().await;
let u = Redacted {
id: 1,
name: Some("A".into()),
password: Some("secret".into()),
..Default::default()
};
let audit = u.audited_create(&b).await.unwrap().unwrap();
assert_eq!(
audit.audited_changes.0.get("password"),
Some(&json!("[REDACTED]"))
);
}
#[tokio::test]
async fn redacted_update_replaces_both_sides() {
let b = backend().await;
let old = Redacted {
id: 1,
password: Some("old".into()),
..Default::default()
};
old.audited_create(&b).await.unwrap();
let new = Redacted {
id: 1,
password: Some("new".into()),
..Default::default()
};
let audit = new.audited_update(&b, &old).await.unwrap().unwrap();
assert_eq!(
audit.audited_changes.0.get("password"),
Some(&json!(["[REDACTED]", "[REDACTED]"]))
);
}
#[tokio::test]
async fn redacted_unchanged_column_absent_from_update() {
let b = backend().await;
let old = Redacted {
id: 1,
name: Some("A".into()),
password: Some("same".into()),
..Default::default()
};
old.audited_create(&b).await.unwrap();
let new = Redacted {
id: 1,
name: Some("B".into()),
password: Some("same".into()),
..Default::default()
};
let audit = new.audited_update(&b, &old).await.unwrap().unwrap();
assert!(!audit.audited_changes.0.contains_key("password"));
assert!(audit.audited_changes.0.contains_key("name"));
}
#[tokio::test]
async fn custom_redaction_value_used_verbatim() {
let b = backend().await;
let u = RedactedCustom {
id: 1,
password: Some("secret".into()),
..Default::default()
};
let audit = u.audited_create(&b).await.unwrap().unwrap();
assert_eq!(audit.audited_changes.0.get("password"), Some(&json!("***")));
}
#[tokio::test]
async fn encrypted_columns_use_filtered_placeholder() {
let b = backend().await;
let u = Encrypted {
id: 1,
password: Some("secret".into()),
..Default::default()
};
let audit = u.audited_create(&b).await.unwrap().unwrap();
assert_eq!(
audit.audited_changes.0.get("password"),
Some(&json!("[FILTERED]"))
);
}
#[tokio::test]
async fn comment_only_update_writes_audit_by_default() {
let b = backend().await;
let old = User::named(1, "A");
old.audited_create(&b).await.unwrap();
let same = User::named(1, "A");
let audit = same
.audited_update_with_comment(&b, &old, "just a note")
.await
.unwrap();
assert!(audit.is_some());
assert_eq!(audit.unwrap().comment.as_deref(), Some("just a note"));
}
#[tokio::test]
async fn comment_only_update_skipped_when_update_with_comment_only_false() {
let b = backend().await;
let old = NoUpdateOnly {
id: 1,
name: Some("A".into()),
..Default::default()
};
old.audited_create(&b).await.unwrap();
let same = NoUpdateOnly {
id: 1,
name: Some("A".into()),
..Default::default()
};
let audit = same
.audited_update_with_comment(&b, &same, "note")
.await
.unwrap();
assert!(audit.is_none());
}
#[tokio::test]
async fn comment_required_blocks_create_without_comment() {
let b = backend().await;
let u = Commented {
id: 1,
name: Some("A".into()),
..Default::default()
};
let err = u.audited_create(&b).await.unwrap_err();
assert!(matches!(
err,
AuditError::CommentRequired {
action: Action::Create
}
));
assert!(
u.audited_create_with_comment(&b, "because")
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn comment_required_aborts_destroy_without_comment() {
let b = backend().await;
let u = Commented {
id: 1,
name: Some("A".into()),
..Default::default()
};
u.audited_create_with_comment(&b, "create").await.unwrap();
let err = u.audited_destroy(&b).await.unwrap_err();
assert!(matches!(
err,
AuditError::CommentRequired {
action: Action::Destroy
}
));
}
#[tokio::test]
async fn comment_required_allows_unchanged_update_without_comment() {
let b = backend().await;
let old = Commented {
id: 1,
name: Some("A".into()),
..Default::default()
};
old.audited_create_with_comment(&b, "c").await.unwrap();
let same = Commented {
id: 1,
name: Some("A".into()),
..Default::default()
};
assert!(same.audited_update(&b, &old).await.unwrap().is_none());
}
#[tokio::test]
async fn max_audits_combines_oldest_and_preserves_versions() {
let b = backend().await;
let mut u = Capped {
id: 1,
name: Some("Foobar".into()),
..Default::default()
};
u.audited_create(&b).await.unwrap();
let old = u.clone();
u.name = Some("Awesome".into());
u.audited_update_with_comment(&b, &old, "first audit comment")
.await
.unwrap();
let old2 = u.clone();
u.status = 7;
u.audited_update_with_comment(&b, &old2, "second audit comment")
.await
.unwrap();
let audits = Capped::audits(&b, 1).await.unwrap();
assert_eq!(audits.len(), 2);
assert_eq!(
audits.iter().map(|a| a.version).collect::<Vec<_>>(),
vec![2, 3]
);
let first = &audits[0];
assert_eq!(first.version, 2);
assert_eq!(
first.audited_changes.0.get("name"),
Some(&json!(["Foobar", "Awesome"]))
);
let comment = first.comment.as_deref().unwrap_or("");
assert!(comment.contains("first audit comment"));
assert!(comment.contains("result of multiple"));
}
#[tokio::test]
async fn max_audits_zero_collapses_history_to_one() {
let b = backend().await;
let mut u = Collapse {
id: 1,
name: Some("A".into()),
..Default::default()
};
u.audited_create(&b).await.unwrap();
let o1 = u.clone();
u.name = Some("B".into());
u.audited_update(&b, &o1).await.unwrap();
let o2 = u.clone();
u.name = Some("C".into());
u.audited_update(&b, &o2).await.unwrap();
let audits = Collapse::audits(&b, 1).await.unwrap();
assert_eq!(audits.len(), 1);
assert_eq!(audits[0].version, 3);
}
struct Vehicle {
id: i64,
kind: String,
name: String,
}
impl Auditable for Vehicle {
fn auditable_type() -> &'static str {
"Vehicle"
}
fn auditable_id(&self) -> AuditId {
self.id.into()
}
fn audited_attributes(&self) -> ValueMap {
let mut m = ValueMap::new();
m.insert("id".into(), json!(self.id));
m.insert("type".into(), json!(self.kind));
m.insert("name".into(), json!(self.name));
m
}
fn audit_options() -> AuditOptions {
AuditOptions::default()
}
fn inheritance_column() -> Option<&'static str> {
Some("type")
}
}
#[tokio::test]
async fn sti_inheritance_column_is_not_audited() {
let b = backend().await;
let v = Vehicle {
id: 1,
kind: "Car".into(),
name: "Beetle".into(),
};
let audit = v.audited_create(&b).await.unwrap().unwrap();
assert!(!audit.audited_changes.0.contains_key("type"));
assert!(audit.audited_changes.0.contains_key("name"));
}
struct Tagged {
id: i64,
tags: Vec<String>,
}
impl Auditable for Tagged {
fn auditable_type() -> &'static str {
"Tagged"
}
fn auditable_id(&self) -> AuditId {
self.id.into()
}
fn audited_attributes(&self) -> ValueMap {
let mut m = ValueMap::new();
m.insert("id".into(), json!(self.id));
m.insert("tags".into(), json!(self.tags));
m
}
fn audit_options() -> AuditOptions {
AuditOptions::builder().redacted(["tags"]).build()
}
}
#[tokio::test]
async fn array_typed_redacted_column_masks_element_wise_on_create() {
let b = backend().await;
let t = Tagged {
id: 1,
tags: vec!["a".into(), "b".into(), "c".into()],
};
let audit = t.audited_create(&b).await.unwrap().unwrap();
assert_eq!(
audit.audited_changes.0.get("tags"),
Some(&json!(["[REDACTED]", "[REDACTED]", "[REDACTED]"]))
);
}
#[tokio::test]
async fn as_user_with_record_sets_polymorphic_user() {
let b = backend().await;
let u = User::named(1, "A");
as_user(Actor::record("Admin", 99), async {
u.audited_create(&b).await
})
.await
.unwrap();
let audit = &User::audits(&b, 1).await.unwrap()[0];
assert_eq!(audit.user_type.as_deref(), Some("Admin"));
assert_eq!(audit.user_id.as_ref().map(|i| i.as_str()), Some("99"));
assert_eq!(audit.username, None);
assert_eq!(audit.user(), Some(Actor::record("Admin", 99)));
}
#[tokio::test]
async fn as_user_with_string_sets_username() {
let b = backend().await;
let u = User::named(1, "A");
as_user("import job", async { u.audited_create(&b).await })
.await
.unwrap();
let audit = &User::audits(&b, 1).await.unwrap()[0];
assert_eq!(audit.username.as_deref(), Some("import job"));
assert_eq!(audit.user_id, None);
assert_eq!(audit.user_type, None);
}
#[tokio::test]
async fn as_user_nests_and_restores() {
let b = backend().await;
let u1 = User::named(1, "A");
let u2 = User::named(2, "B");
let u3 = User::named(3, "C");
as_user("outer", async {
u1.audited_create(&b).await.unwrap();
as_user("inner", async { u2.audited_create(&b).await })
.await
.unwrap();
u3.audited_create(&b).await.unwrap();
})
.await;
let a1 = &User::audits(&b, 1).await.unwrap()[0];
let a2 = &User::audits(&b, 2).await.unwrap()[0];
let a3 = &User::audits(&b, 3).await.unwrap()[0];
assert_eq!(a1.username.as_deref(), Some("outer"));
assert_eq!(a2.username.as_deref(), Some("inner"));
assert_eq!(a3.username.as_deref(), Some("outer")); }
#[tokio::test]
async fn request_uuid_always_present() {
let b = backend().await;
let u = User::named(1, "A");
u.audited_create(&b).await.unwrap();
let audit = &User::audits(&b, 1).await.unwrap()[0];
assert!(audit.request_uuid.as_deref().is_some_and(|s| !s.is_empty()));
assert_eq!(audit.remote_address, None);
}
#[tokio::test]
async fn without_auditing_scope_suppresses() {
let b = backend().await;
let u = User::named(1, "A");
without_auditing(async { u.audited_create(&b).await })
.await
.unwrap();
assert_eq!(User::audits(&b, 1).await.unwrap().len(), 0);
u.audited_create(&b).await.unwrap();
assert_eq!(User::audits(&b, 1).await.unwrap().len(), 1);
}
#[tokio::test]
async fn with_auditing_forces_enable_for_disabled_type() {
let b = backend().await;
Disablable::disable_auditing();
let u = Disablable {
id: 1,
name: Some("A".into()),
..Default::default()
};
assert!(u.audited_create(&b).await.unwrap().is_none());
with_auditing(async { u.audited_create(&b).await })
.await
.unwrap();
assert_eq!(Disablable::audits(&b, 1).await.unwrap().len(), 1);
Disablable::enable_auditing();
}
#[tokio::test]
async fn without_auditing_is_task_isolated() {
let b = backend().await;
let a = User::named(1, "A"); let c = User::named(2, "C"); let (_x, _y) = tokio::join!(
without_auditing(async { a.audited_create(&b).await.unwrap() }),
async { c.audited_create(&b).await.unwrap() },
);
assert_eq!(User::audits(&b, 1).await.unwrap().len(), 0);
assert_eq!(User::audits(&b, 2).await.unwrap().len(), 1);
}
struct Conditional {
id: i64,
active: bool,
name: String,
}
impl Auditable for Conditional {
fn auditable_type() -> &'static str {
"Conditional"
}
fn auditable_id(&self) -> AuditId {
self.id.into()
}
fn audited_attributes(&self) -> ValueMap {
let mut m = ValueMap::new();
m.insert("id".into(), json!(self.id));
m.insert("name".into(), json!(self.name));
m
}
fn audit_options() -> AuditOptions {
AuditOptions::default()
}
fn audit_if(&self) -> bool {
self.active
}
}
#[tokio::test]
async fn instance_if_condition_gates_auditing() {
let b = backend().await;
let inactive = Conditional {
id: 1,
active: false,
name: "A".into(),
};
assert!(inactive.audited_create(&b).await.unwrap().is_none());
let active = Conditional {
id: 2,
active: true,
name: "B".into(),
};
assert!(active.audited_create(&b).await.unwrap().is_some());
}
#[tokio::test]
async fn revisions_reconstruct_each_version() {
let b = backend().await;
let mut u = User::named(1, "A");
u.audited_create(&b).await.unwrap();
let o1 = u.clone();
u.name = Some("B".into());
u.audited_update(&b, &o1).await.unwrap();
let o2 = u.clone();
u.name = Some("C".into());
u.audited_update(&b, &o2).await.unwrap();
let revs = User::revisions(&b, 1).await.unwrap();
assert_eq!(revs.len(), 3);
assert_eq!(revs[0].attributes.get("name"), Some(&json!("A")));
assert_eq!(revs[1].attributes.get("name"), Some(&json!("B")));
assert_eq!(revs[2].attributes.get("name"), Some(&json!("C")));
assert_eq!(revs[0].version, 1);
assert_eq!(revs[2].version, 3);
}
#[tokio::test]
async fn revision_at_version_and_previous_and_out_of_range() {
let b = backend().await;
let mut u = User::named(1, "A");
u.audited_create(&b).await.unwrap();
let o1 = u.clone();
u.name = Some("B".into());
u.audited_update(&b, &o1).await.unwrap();
let o2 = u.clone();
u.name = Some("C".into());
u.audited_update(&b, &o2).await.unwrap();
assert_eq!(
User::revision(&b, 1, 1)
.await
.unwrap()
.unwrap()
.attributes
.get("name"),
Some(&json!("A"))
);
assert_eq!(
User::revision(&b, 1, 2)
.await
.unwrap()
.unwrap()
.attributes
.get("name"),
Some(&json!("B"))
);
assert!(User::revision(&b, 1, 4).await.unwrap().is_none());
assert_eq!(
User::revision_previous(&b, 1)
.await
.unwrap()
.unwrap()
.attributes
.get("name"),
Some(&json!("B"))
);
}
#[tokio::test]
async fn destroyed_record_reconstructs_as_new_record() {
let b = backend().await;
let u = User::named(1, "A");
u.audited_create(&b).await.unwrap();
u.audited_destroy(&b).await.unwrap();
let revs = User::revisions(&b, 1).await.unwrap();
let last = revs.last().unwrap();
assert!(last.new_record);
assert_eq!(last.attributes.get("name"), Some(&json!("A")));
}
#[tokio::test]
async fn undo_plans_reverse_each_action() {
let b = backend().await;
let mut u = User::named(1, "A");
let create = u.audited_create(&b).await.unwrap().unwrap();
assert_eq!(create.undo_plan().unwrap(), UndoPlan::Delete);
let old = u.clone();
u.name = Some("B".into());
let update = u.audited_update(&b, &old).await.unwrap().unwrap();
match update.undo_plan().unwrap() {
UndoPlan::Restore(attrs) => assert_eq!(attrs.get("name"), Some(&json!("A"))),
other => panic!("expected Restore, got {other:?}"),
}
let destroy = u.audited_destroy(&b).await.unwrap().unwrap();
match destroy.undo_plan().unwrap() {
UndoPlan::Recreate(attrs) => assert_eq!(attrs.get("name"), Some(&json!("B"))),
other => panic!("expected Recreate, got {other:?}"),
}
}
#[tokio::test]
async fn scope_query_filters_by_action_and_order() {
let b = backend().await;
let mut u = User::named(1, "A");
u.audited_create(&b).await.unwrap();
let o1 = u.clone();
u.name = Some("B".into());
u.audited_update(&b, &o1).await.unwrap();
let o2 = u.clone();
u.name = Some("C".into());
u.audited_update(&b, &o2).await.unwrap();
let updates = User::query(&b, 1).updates().fetch().await.unwrap();
assert_eq!(updates.len(), 2);
assert!(updates.iter().all(|a| a.action == Action::Update));
let creates = User::query(&b, 1).creates().fetch().await.unwrap();
assert_eq!(creates.len(), 1);
let desc = User::query(&b, 1)
.descending()
.limit(1)
.fetch()
.await
.unwrap();
assert_eq!(desc[0].version, 3);
let from2 = User::query(&b, 1).from_version(2).fetch().await.unwrap();
assert_eq!(
from2.iter().map(|a| a.version).collect::<Vec<_>>(),
vec![2, 3]
);
}
struct Company {
id: i64,
name: String,
}
impl Auditable for Company {
fn auditable_type() -> &'static str {
"Company"
}
fn auditable_id(&self) -> AuditId {
self.id.into()
}
fn audited_attributes(&self) -> ValueMap {
let mut m = ValueMap::new();
m.insert("id".into(), json!(self.id));
m.insert("name".into(), json!(self.name));
m
}
fn audit_options() -> AuditOptions {
AuditOptions::default()
}
}
struct Employee {
id: i64,
name: String,
company_id: i64,
}
impl Auditable for Employee {
fn auditable_type() -> &'static str {
"Employee"
}
fn auditable_id(&self) -> AuditId {
self.id.into()
}
fn audited_attributes(&self) -> ValueMap {
let mut m = ValueMap::new();
m.insert("id".into(), json!(self.id));
m.insert("name".into(), json!(self.name));
m.insert("company_id".into(), json!(self.company_id));
m
}
fn audit_options() -> AuditOptions {
AuditOptions::builder().associated_with("Company").build()
}
fn audit_associated(&self) -> Option<(String, AuditId)> {
Some(("Company".to_string(), self.company_id.into()))
}
}
#[tokio::test]
async fn associated_audits_are_recorded_and_queryable() {
let b = backend().await;
let company = Company {
id: 10,
name: "Acme".into(),
};
company.audited_create(&b).await.unwrap();
let emp = Employee {
id: 1,
name: "Alice".into(),
company_id: 10,
};
let audit = emp.audited_create(&b).await.unwrap().unwrap();
assert_eq!(audit.associated_type.as_deref(), Some("Company"));
assert_eq!(audit.associated_id.as_ref().map(|i| i.as_str()), Some("10"));
let assoc = Company::associated_audits(&b, 10).await.unwrap();
assert_eq!(assoc.len(), 1);
assert_eq!(assoc[0].auditable_type, "Employee");
let combined = Company::own_and_associated_audits(&b, 10).await.unwrap();
assert_eq!(combined.len(), 2);
}