architecturekit 0.14.0

Building blocks for DDD-based applications with CQRS and event sourcing, on top of the EventSourcingDB by the native web.
Documentation
// These tests mirror TestTypedProjection of ArchitectureKit for Go, through
// Projected, which turns a stored event into what a projection gets. A
// projection names its events as an enum or as the stored event, so what Go
// checks while a typed projection is being built has nothing to check in
// Rust: a nil handler, a pointer or an interface as the event type, two
// handlers for one type, and calling UpcastWith twice or without a set. A
// projection is resumable by its trait, not by embedding.

use std::sync::LazyLock;

use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

use crate::{
    Categorized, Category, Envelope, Event, Events, Failure, Permanent, Projected, ProjectionError,
    State, Transient, UpcastEvent, Upcasters, replay_stored,
};

#[derive(Debug, PartialEq, Serialize, Deserialize, Event)]
#[event(type = "io.thenativeweb.test.book-acquired")]
struct BookAcquired {
    title: String,
}

#[derive(Debug, PartialEq, Serialize, Deserialize, Event)]
#[event(type = "io.thenativeweb.test.book-borrowed")]
struct BookBorrowed {
    reader: String,
}

#[derive(Debug, PartialEq, Events)]
enum BookEvent {
    Acquired(BookAcquired),
    Borrowed(BookBorrowed),
}

/// A stored event, as the database hands it out.
pub(crate) fn stored(id: &str, event_type: &str, data: Value) -> eventsourcingdb::Event {
    let mut event = json!({
        "specversion": "1.0",
        "id": id,
        "time": "2026-10-27T12:00:00Z",
        "source": "https://thenativeweb.io",
        "subject": "/books/42",
        "type": event_type,
        "datacontenttype": "application/json",
        "hash": "0".repeat(64),
        "predecessorhash": "0".repeat(64),
        "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
    });
    event["data"] = data;

    serde_json::from_value(event).expect("a stored event is JSON")
}

#[test]
fn hands_over_the_metadata_and_the_decoded_data() {
    let event = stored("7", BookAcquired::EVENT_TYPE, json!({ "title": "2001" }));

    let envelopes = BookEvent::project(&event, None).expect("the event is projected");

    assert_eq!(
        envelopes,
        [Envelope {
            id: "7".to_owned(),
            time: *event.time(),
            source: "https://thenativeweb.io".to_owned(),
            subject: "/books/42".to_owned(),
            event_type: BookAcquired::EVENT_TYPE.to_owned(),
            trace_parent: Some(
                "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01".to_owned()
            ),
            trace_state: None,
            part: 0,
            data: BookEvent::Acquired(BookAcquired {
                title: "2001".to_owned()
            }),
        }]
    );
}

#[test]
fn hands_each_event_its_variant() {
    let borrowed = stored("1", BookBorrowed::EVENT_TYPE, json!({ "reader": "Jane" }));

    let envelopes = BookEvent::project(&borrowed, None).expect("the event is projected");

    assert_eq!(
        envelopes[0].data,
        BookEvent::Borrowed(BookBorrowed {
            reader: "Jane".to_owned()
        })
    );
}

#[test]
fn leaves_out_events_of_types_without_a_variant() {
    let other = stored("1", "io.thenativeweb.test.reader-registered", json!({}));

    assert_eq!(
        BookEvent::project(&other, None).expect("the event is left out"),
        []
    );
}

#[test]
fn fails_permanently_on_data_that_does_not_match() {
    let event = stored("3", BookAcquired::EVENT_TYPE, json!({ "title": 42 }));

    let failure = BookEvent::project(&event, None).expect_err("the data does not match");

    assert_eq!(failure.category(), Some(Category::Permanent));
    assert_eq!(
        failure.to_string(),
        r#"permanent failure: decoding event 3 of type "io.thenativeweb.test.book-acquired""#
    );
}

static UPCASTERS: LazyLock<Upcasters> = LazyLock::new(|| {
    Upcasters::new()
        .upcast("io.thenativeweb.test.book-acquired.v1", |event| {
            Ok::<_, serde_json::Error>(vec![
                event
                    .with_type(BookAcquired::EVENT_TYPE)
                    .with_data(json!({ "title": "from the old shape" })),
            ])
        })
        .upcast("io.thenativeweb.test.book-acquired-and-borrowed", |event| {
            Ok::<_, serde_json::Error>(vec![
                UpcastEvent::clone(&event)
                    .with_type(BookAcquired::EVENT_TYPE)
                    .with_data(json!({ "title": "2001" })),
                event
                    .with_type(BookBorrowed::EVENT_TYPE)
                    .with_data(json!({ "reader": "Jane" })),
            ])
        })
        .upcast("io.thenativeweb.test.book-lost", |_| {
            Err::<Vec<UpcastEvent>, _>("this one cannot be migrated")
        })
});

#[test]
fn runs_the_upcasters() {
    let old = stored(
        "1",
        "io.thenativeweb.test.book-acquired.v1",
        json!({ "whatever": true }),
    );

    let envelopes = BookEvent::project(&old, Some(&UPCASTERS)).expect("the event is projected");

    assert_eq!(envelopes[0].event_type, BookAcquired::EVENT_TYPE);
    assert_eq!(
        envelopes[0].data,
        BookEvent::Acquired(BookAcquired {
            title: "from the old shape".to_owned()
        })
    );
}

#[test]
fn hands_out_every_event_an_upcaster_produces_with_its_part() {
    let split = stored(
        "5",
        "io.thenativeweb.test.book-acquired-and-borrowed",
        json!({}),
    );

    let envelopes = BookEvent::project(&split, Some(&UPCASTERS)).expect("the event is projected");

    let parts: Vec<(&str, usize)> = envelopes
        .iter()
        .map(|envelope| (envelope.id.as_str(), envelope.part))
        .collect();
    assert_eq!(parts, [("5", 0), ("5", 1)]);
    assert!(matches!(envelopes[1].data, BookEvent::Borrowed(_)));
}

#[test]
fn reports_a_failing_upcaster_as_permanent() {
    let lost = stored("2", "io.thenativeweb.test.book-lost", json!({}));

    let failure = BookEvent::project(&lost, Some(&UPCASTERS)).expect_err("the upcaster fails");

    assert_eq!(failure.category(), Some(Category::Permanent));
}

#[derive(Clone, Debug, Default)]
struct Shelf {
    titles: Vec<String>,
}

impl State for Shelf {
    type Event = BookEvent;

    fn evolve(mut self, event: &BookEvent) -> Self {
        if let BookEvent::Acquired(acquired) = event {
            self.titles.push(acquired.title.clone());
        }
        self
    }

    fn upcasters() -> Option<&'static Upcasters> {
        Some(&UPCASTERS)
    }
}

#[test]
fn sees_what_a_state_with_the_same_upcasters_sees() {
    let history = [
        stored("1", "io.thenativeweb.test.book-acquired.v1", json!({})),
        stored(
            "2",
            "io.thenativeweb.test.book-acquired-and-borrowed",
            json!({}),
        ),
    ];

    let shelf: Shelf =
        replay_stored(history.iter().map(UpcastEvent::from_stored)).expect("the state is replayed");
    let projected: Vec<String> = history
        .iter()
        .flat_map(|event| BookEvent::project(event, Some(&UPCASTERS)).expect("projected"))
        .filter_map(|envelope| match envelope.data {
            BookEvent::Acquired(acquired) => Some(acquired.title),
            BookEvent::Borrowed(_) => None,
        })
        .collect();

    assert_eq!(projected, shelf.titles);
}

#[test]
fn hands_a_projection_of_stored_events_every_event_as_it_is_stored() {
    let event = stored(
        "4",
        "io.thenativeweb.test.book-acquired.v1",
        json!({ "whatever": true }),
    );

    let envelopes =
        eventsourcingdb::Event::project(&event, Some(&UPCASTERS)).expect("the event is projected");

    assert_eq!(envelopes.len(), 1);
    assert_eq!(
        envelopes[0].data, event,
        "no upcaster runs, and the hash stays"
    );
    assert_eq!(
        envelopes[0].event_type,
        "io.thenativeweb.test.book-acquired.v1"
    );
}

#[test]
fn names_the_category_of_every_failure() {
    let failure = || Failure::new("failed", None);
    let panicked = ProjectionError::Panicked {
        message: "boom".to_owned(),
    };

    for (name, error, category, has_panicked) in [
        (
            "transient",
            ProjectionError::Transient(Transient::Other(failure())),
            Some(Category::Transient),
            false,
        ),
        (
            "permanent",
            ProjectionError::Permanent(Permanent::Other(failure())),
            Some(Category::Permanent),
            false,
        ),
        (
            "of the projection",
            ProjectionError::of(crate::DomainError::new("refused")),
            Some(Category::Domain),
            false,
        ),
        (
            "panicked",
            ProjectionError::Panicked {
                message: "boom".to_owned(),
            },
            Some(Category::Permanent),
            true,
        ),
        (
            "rolled back after a transient failure",
            ProjectionError::RolledBack {
                cause: Box::new(ProjectionError::Transient(Transient::Other(failure()))),
                rollback: Box::new(ProjectionError::Permanent(Permanent::Other(failure()))),
            },
            Some(Category::Transient),
            false,
        ),
        (
            "rolled back with a panic after a transient failure",
            ProjectionError::RolledBack {
                cause: Box::new(ProjectionError::Transient(Transient::Other(failure()))),
                rollback: Box::new(panicked),
            },
            Some(Category::Permanent),
            true,
        ),
    ] {
        assert_eq!(error.category(), category, "{name}");
        assert_eq!(error.has_panicked(), has_panicked, "{name}");
    }
}

#[test]
fn reports_a_panic_with_its_message() {
    let text = ProjectionError::panicked(&"boom");
    let formatted = ProjectionError::panicked(&String::from("boom"));
    let other = ProjectionError::panicked(&42);

    assert_eq!(
        text.to_string(),
        "permanent failure: the projection panicked: boom"
    );
    assert_eq!(formatted.to_string(), text.to_string());
    assert_eq!(
        other.to_string(),
        "permanent failure: the projection panicked: a value that is no text"
    );
}