#![allow(
clippy::unwrap_used,
clippy::disallowed_methods,
clippy::cast_possible_truncation,
clippy::needless_borrows_for_generic_args,
clippy::panic
)]
mod support;
use batpak::store::{Store, StoreConfig, StoreError, StoreStats};
use batpak::typestate::Transition;
use serde::{Deserialize, Serialize};
use support::prelude::*;
use tempfile::TempDir;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, batpak::EventPayload)]
#[batpak(category = 0x0A, type_id = 1)]
struct PublishedDoc {
title: String,
from: String,
to: String,
}
#[path = "support/small_store.rs"]
mod small_store_support;
fn test_store() -> (TempDir, Store) {
small_store_support::small_segment_store().expect("small segment store")
}
#[test]
fn append_reaction_links_causation() {
let (_dir, store) = test_store();
let coord = Coordinate::new("entity:react", "scope:test").expect("valid coord");
let kind_cmd = EventKind::custom(0xF, 1);
let kind_evt = EventKind::custom(0xF, 2);
let root = store
.append(&coord, kind_cmd, &serde_json::json!({"cmd": "create"}))
.expect("root append");
let reaction = store
.append_reaction(
&coord,
kind_evt,
&serde_json::json!({"evt": "created"}),
batpak::id::CorrelationId::from(u128::from(root.event_id)),
batpak::id::CausationId::from(u128::from(root.event_id)),
)
.expect("reaction append");
assert_ne!(
root.event_id, reaction.event_id,
"PROPERTY: append_reaction must produce a new unique event_id distinct from its cause.\n\
Investigate: src/store/mod.rs append_reaction.\n\
Common causes: event_id generation reuses the cause ID, hash collision in tiny test set.\n\
Run: cargo test --test store_advanced append_reaction_links_causation"
);
let root_stored = store.get(root.event_id).expect("get root");
let react_stored = store.get(reaction.event_id).expect("get reaction");
assert_eq!(
root_stored.event.event_kind(),
kind_cmd,
"PROPERTY: root event must retain its original EventKind after being stored.\n\
Investigate: src/store/mod.rs append, src/store/segment/mod.rs write_frame.\n\
Common causes: event_kind field not serialised, wrong frame read back.\n\
Run: cargo test --test store_advanced append_reaction_links_causation"
);
assert_eq!(
react_stored.event.event_kind(),
kind_evt,
"PROPERTY: reaction event must retain its EventKind (kind_evt) after storage.\n\
Investigate: src/store/mod.rs append_reaction, src/store/segment/mod.rs write_frame.\n\
Common causes: reaction inherits cause kind instead of its own, serialisation bug.\n\
Run: cargo test --test store_advanced append_reaction_links_causation"
);
store.close().expect("close");
}
#[test]
fn cas_fails_on_wrong_sequence() {
let (_dir, store) = test_store();
let coord = Coordinate::new("entity:cas-fail", "scope:test").expect("valid coord");
let kind = EventKind::custom(0xF, 1);
store
.append(&coord, kind, &serde_json::json!({"x": 1}))
.expect("first");
store
.append(&coord, kind, &serde_json::json!({"x": 2}))
.expect("second");
let opts = batpak::store::AppendOptions {
expected_sequence: Some(0),
..Default::default()
};
let result = store.append_with_options(&coord, kind, &serde_json::json!({"x": 3}), opts);
let err = match result {
Ok(_) => panic!(
"PROPERTY: append_with_options must return Err when expected_sequence is stale (CAS failure).\
Investigate: src/store/mod.rs append_with_options CAS check.\
Common causes: sequence comparison uses wrong field, CAS check skipped under lock."
),
Err(err) => err,
};
assert!(
matches!(err, StoreError::SequenceMismatch { .. }),
"PROPERTY: CAS failure must surface as StoreError::SequenceMismatch, got {err:?}"
);
store.close().expect("close");
}
#[test]
fn idempotency_returns_same_receipt() {
let (_dir, store) = test_store();
let coord = Coordinate::new("entity:idemp", "scope:test").expect("valid coord");
let kind = EventKind::custom(0xF, 1);
let key: u128 = 0xDEAD_BEEF_CAFE_BABE_1234_5678_9ABC_DEF0;
let opts = batpak::store::AppendOptions {
idempotency_key: Some(batpak::id::IdempotencyKey::from(key)),
..Default::default()
};
let r1 = store
.append_with_options(&coord, kind, &serde_json::json!({"x": 1}), opts.clone())
.expect("first append");
let r2 = store
.append_with_options(&coord, kind, &serde_json::json!({"x": 2}), opts)
.expect("idempotent append");
assert_eq!(
r1.event_id, r2.event_id,
"PROPERTY: append_with_options with the same idempotency_key must return the same event_id.\n\
Investigate: src/store/mod.rs append_with_options idempotency check.\n\
Common causes: idempotency key not stored after first write, key lookup hash collision.\n\
Run: cargo test --test store_advanced idempotency_returns_same_receipt"
);
let stats: StoreStats = store.stats();
assert_eq!(
stats.event_count, 2,
"PROPERTY: idempotent appends must not increase event_count beyond the lifecycle event plus one stored user event.\n\
Investigate: src/store/mod.rs append_with_options idempotency check.\n\
Common causes: idempotency key lookup misses in-memory cache, duplicate written to segment.\n\
Run: cargo test --test store_advanced idempotency_returns_same_receipt"
);
store.close().expect("close");
}
batpak::define_state_machine!(document_state_seal, DocumentState { Draft, Published });
#[test]
fn apply_transition_persists_event() {
let (_dir, store) = test_store();
let coord = Coordinate::new("entity:transition", "scope:test").expect("valid coord");
let kind = <PublishedDoc as batpak::EventPayload>::KIND;
let transition = Transition::<Draft, Published, PublishedDoc>::from_payload(PublishedDoc {
title: "hello".into(),
from: "draft".into(),
to: "published".into(),
});
let receipt = store
.apply_transition(&coord, transition)
.expect("apply_transition");
let stored = store.get(receipt.event_id).expect("get transition event");
assert_eq!(
stored.event.event_kind(),
kind,
"PROPERTY: apply_transition must persist the EventKind carried by the Transition.\n\
Investigate: src/store/mod.rs apply_transition, src/typestate/mod.rs Transition.\n\
Common causes: transition payload serialised without kind, wrong kind written to frame.\n\
Run: cargo test --test store_advanced apply_transition_persists_event"
);
assert_eq!(
stored.coordinate, coord,
"PROPERTY: apply_transition must persist the event under the supplied Coordinate.\n\
Investigate: src/store/mod.rs apply_transition.\n\
Common causes: coordinate not forwarded to inner append call, coordinate field swapped.\n\
Run: cargo test --test store_advanced apply_transition_persists_event"
);
store.close().expect("close");
}
#[test]
fn with_correlation_sets_header_correlation_id() {
let dir = TempDir::new().expect("create temp dir");
let config = StoreConfig::new(dir.path())
.with_segment_max_bytes(4096)
.with_sync_every_n_events(1);
let store = Store::open(config).expect("open store");
let coord = Coordinate::new("entity:corr", "scope:test").expect("valid coord");
let kind = EventKind::custom(0xF, 1);
let custom_corr: u128 = 0xDEAD_BEEF_CAFE_BABE_1234_5678_9ABC_DEF0;
let opts = AppendOptions::new().with_correlation(batpak::id::CorrelationId::from(custom_corr));
let receipt = store
.append_with_options(&coord, kind, &"corr_test", opts)
.expect("append with correlation");
let event = store.get(receipt.event_id).expect("get event");
assert_eq!(
event.event.header.correlation_id,
batpak::id::CorrelationId::from(custom_corr),
"WITH_CORRELATION: correlation_id on stored event should match the value \
set via AppendOptions::with_correlation().\n\
Investigate: src/store/mod.rs append_with_options → writer.rs AppendGuards.\n\
Common causes: correlation_id not propagated from AppendOptions to EventHeader."
);
}
#[test]
fn with_causation_sets_header_causation_id() {
let dir = TempDir::new().expect("create temp dir");
let config = StoreConfig::new(dir.path())
.with_segment_max_bytes(4096)
.with_sync_every_n_events(1);
let store = Store::open(config).expect("open store");
let coord = Coordinate::new("entity:caus", "scope:test").expect("valid coord");
let kind = EventKind::custom(0xF, 1);
let custom_cause: u128 = 0x1111_2222_3333_4444_5555_6666_7777_8888;
let opts = AppendOptions::new().with_causation(batpak::id::CausationId::from(custom_cause));
let receipt = store
.append_with_options(&coord, kind, &"cause_test", opts)
.expect("append with causation");
let event = store.get(receipt.event_id).expect("get event");
assert_eq!(
event.event.header.causation_id,
Some(batpak::id::CausationId::from(custom_cause)),
"WITH_CAUSATION: causation_id on stored event should match the value \
set via AppendOptions::with_causation().\n\
Investigate: src/store/mod.rs append_with_options → writer.rs AppendGuards.\n\
Common causes: causation_id not propagated from AppendOptions to EventHeader."
);
}
#[test]
fn with_correlation_and_causation_combined() {
let dir = TempDir::new().expect("create temp dir");
let config = StoreConfig::new(dir.path())
.with_segment_max_bytes(4096)
.with_sync_every_n_events(1);
let store = Store::open(config).expect("open store");
let coord = Coordinate::new("entity:both", "scope:test").expect("valid coord");
let kind = EventKind::custom(0xF, 1);
let corr: u128 = 0xAAAA_BBBB_CCCC_DDDD_EEEE_FFFF_0000_1111;
let cause: u128 = 0x2222_3333_4444_5555_6666_7777_8888_9999;
let opts = AppendOptions::new()
.with_correlation(batpak::id::CorrelationId::from(corr))
.with_causation(batpak::id::CausationId::from(cause));
let receipt = store
.append_with_options(&coord, kind, &"both_test", opts)
.expect("append with both");
let event = store.get(receipt.event_id).expect("get event");
assert_eq!(
event.event.header.correlation_id,
batpak::id::CorrelationId::from(corr),
"COMBINED: correlation_id should be set when both with_correlation and with_causation used."
);
assert_eq!(
event.event.header.causation_id,
Some(batpak::id::CausationId::from(cause)),
"COMBINED: causation_id should be set when both with_correlation and with_causation used."
);
let default_receipt = store
.append(&coord, kind, &"default_test")
.expect("default append");
let default_event = store.get(default_receipt.event_id).expect("get default");
assert_ne!(
default_event.event.header.correlation_id,
batpak::id::CorrelationId::from(corr),
"VARIANCE: default append should auto-generate a different correlation_id."
);
assert_eq!(
default_event.event.header.causation_id, None,
"VARIANCE: default append should have None causation_id."
);
}