auditlog 0.1.0

Audit trail for your data models — an ORM-agnostic core with a pluggable, async sqlx backend (SQLite & Postgres).
Documentation
//! Dockerized Postgres integration tests.
//!
//! These exercise the real `SqlxBackend` Postgres path end-to-end (transactional version
//! assignment, `$n` placeholder SQL, JSON `audited_changes` round-trip, RFC 3339 `created_at`
//! ordering, the `max_audits` combine transaction, and associated audits) against a throwaway
//! Postgres container.
//!
//! Gated behind the `pg-tests` feature so normal `cargo test` stays fast and Docker-free. Run with:
//!
//! ```sh
//! cargo test --features pg-tests
//! ```
//!
//! If Docker is not available the single test prints a SKIP notice and passes, so it is safe to
//! include in a CI matrix that may or may not provide Docker.
#![cfg(feature = "pg-tests")]

use std::time::Duration;

use auditlog::{Action, Auditable, AuditId, AuditOptions, SqlxBackend, ValueMap};
use serde_json::json;
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::ContainerAsync;
use testcontainers_modules::testcontainers::runners::AsyncRunner;

// ---------- models ----------

#[derive(Clone)]
struct Doc {
    id: i64,
    title: String,
    status: i64,
}

impl Auditable for Doc {
    fn auditable_type() -> &'static str {
        "Doc"
    }
    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("title".into(), json!(self.title));
        m.insert("status".into(), json!(self.status));
        m
    }
    fn audit_options() -> AuditOptions {
        AuditOptions::default()
    }
}

#[derive(Clone)]
struct Capped {
    id: i64,
    title: String,
    status: i64,
}

impl Auditable for Capped {
    fn auditable_type() -> &'static str {
        "Capped"
    }
    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("title".into(), json!(self.title));
        m.insert("status".into(), json!(self.status));
        m
    }
    fn audit_options() -> AuditOptions {
        AuditOptions::builder().max_audits(2).build()
    }
}

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()))
    }
}

// ---------- harness ----------

/// Start a throwaway Postgres container and return it (kept alive by the caller) plus a migrated
/// backend. Returns `None` if Docker is unavailable.
async fn start_pg() -> Option<(ContainerAsync<Postgres>, SqlxBackend)> {
    let container = match Postgres::default().start().await {
        Ok(c) => c,
        Err(e) => {
            eprintln!("SKIP postgres integration tests: could not start container (is Docker running?): {e}");
            return None;
        }
    };
    let host = container.get_host().await.expect("container host");
    let port = container
        .get_host_port_ipv4(5432)
        .await
        .expect("container port");
    let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");

    // Postgres may need a moment after the container reports ready; retry the initial connect.
    let mut backend = None;
    for _ in 0..40 {
        match SqlxBackend::connect_postgres(&url).await {
            Ok(b) => {
                backend = Some(b);
                break;
            }
            Err(_) => tokio::time::sleep(Duration::from_millis(250)).await,
        }
    }
    let backend = backend.expect("connect to postgres container");
    backend.migrate().await.expect("migrate");
    Some((container, backend))
}

#[tokio::test]
async fn postgres_end_to_end() {
    let Some((_container, b)) = start_pg().await else {
        return; // Docker unavailable — skip.
    };

    // ----- create / update / destroy shapes + versioning -----
    let mut doc = Doc {
        id: 1,
        title: "Hello".into(),
        status: 0,
    };
    let create = doc.audited_create(&b).await.unwrap().unwrap();
    assert_eq!(create.action, Action::Create);
    assert_eq!(create.version, 1);
    assert_eq!(create.audited_changes.0.get("title"), Some(&json!("Hello")));
    assert!(create.request_uuid.as_deref().is_some_and(|s| !s.is_empty()));

    let old = doc.clone();
    doc.title = "Hello, world".into();
    let update = doc.audited_update(&b, &old).await.unwrap().unwrap();
    assert_eq!(update.version, 2);
    assert_eq!(
        update.audited_changes.0.get("title"),
        Some(&json!(["Hello", "Hello, world"]))
    );

    let destroy = doc.audited_destroy(&b).await.unwrap().unwrap();
    assert_eq!(destroy.version, 3);
    assert_eq!(destroy.audited_changes.0.get("title"), Some(&json!("Hello, world")));

    let audits = Doc::audits(&b, 1).await.unwrap();
    assert_eq!(audits.iter().map(|a| a.version).collect::<Vec<_>>(), vec![1, 2, 3]);

    // ----- scopes / filters ($n placeholders, ordering) -----
    assert_eq!(Doc::query(&b, 1).updates().count().await.unwrap(), 1);
    assert_eq!(Doc::query(&b, 1).creates().count().await.unwrap(), 1);
    assert_eq!(Doc::query(&b, 1).destroys().count().await.unwrap(), 1);
    let desc = Doc::query(&b, 1).descending().limit(1).fetch().await.unwrap();
    assert_eq!(desc[0].version, 3);
    let from2 = Doc::query(&b, 1).from_version(2).fetch().await.unwrap();
    assert_eq!(from2.iter().map(|a| a.version).collect::<Vec<_>>(), vec![2, 3]);

    // ----- revisions (RFC3339 created_at + fold) -----
    let v1 = Doc::revision(&b, 1, 1).await.unwrap().unwrap();
    assert_eq!(v1.attributes.get("title"), Some(&json!("Hello")));
    let now = chrono::Utc::now();
    let at_now = Doc::revision_at(&b, 1, now).await.unwrap();
    assert!(at_now.is_some());
    let before = Doc::revision_at(&b, 1, now - chrono::Duration::days(1))
        .await
        .unwrap();
    assert!(before.is_none());

    // ----- max_audits combine transaction (UPDATE + DELETE IN (...)) -----
    let mut c = Capped {
        id: 1,
        title: "Foobar".into(),
        status: 0,
    };
    c.audited_create(&b).await.unwrap(); // v1
    let o1 = c.clone();
    c.title = "Awesome".into();
    c.audited_update_with_comment(&b, &o1, "first audit comment")
        .await
        .unwrap(); // v2
    let o2 = c.clone();
    c.status = 7;
    c.audited_update_with_comment(&b, &o2, "second audit comment")
        .await
        .unwrap(); // v3
    let capped = Capped::audits(&b, 1).await.unwrap();
    assert_eq!(capped.len(), 2);
    assert_eq!(capped.iter().map(|a| a.version).collect::<Vec<_>>(), vec![2, 3]);
    assert_eq!(
        capped[0].audited_changes.0.get("title"),
        Some(&json!(["Foobar", "Awesome"]))
    );
    let comment = capped[0].comment.as_deref().unwrap_or("");
    assert!(comment.contains("first audit comment"));
    assert!(comment.contains("result of multiple"));

    // ----- associated audits -----
    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 emp_audit = emp.audited_create(&b).await.unwrap().unwrap();
    assert_eq!(emp_audit.associated_type.as_deref(), Some("Company"));
    assert_eq!(emp_audit.associated_id.as_ref().map(|i| i.as_str()), Some("10"));
    assert_eq!(Company::associated_audits(&b, 10).await.unwrap().len(), 1);
    assert_eq!(
        Company::own_and_associated_audits(&b, 10).await.unwrap().len(),
        2
    );
}