use chrono::{TimeZone, Utc};
use proptest::prelude::*;
use skilj_core::access_control::{self, EventReadStartPosition, EventReadToken, TokenStatus};
use skilj_core::error::SkiljRejection;
use skilj_core::event_store::{
self, AckMode, BoundedContext, BoundedContextStatus, ConsumeEventsResult, CursorUpdate, Event,
EventOrigin, EventType, ReadCursor,
};
use skilj_core::shared::{Filter, FilterOperator};
fn bounded_context(name: &str) -> BoundedContext {
BoundedContext {
name: name.into(),
status: BoundedContextStatus::Active,
created_at: timestamp(0),
created_by: skilj_core::bootstrap::ContextCreator::SystemCreator,
template: None,
}
}
fn event_type(event_read_allowed: bool) -> EventType {
EventType {
bounded_context: bounded_context("orders"),
name: "OrderPlaced".into(),
schema: "{}".into(),
schema_version: 1,
tag_mappings: Vec::new(),
owner_tag_key: None,
sensitive_fields: Vec::new(),
private_fields: Vec::new(),
external_creation_allowed: false,
direct_creation_allowed: false,
system_triggered_allowed: false,
system_triggered_schedule: None,
missed_occurrence_policy: None,
schedule_position: None,
last_fired_at: None,
event_read_allowed,
}
}
fn other_event_type() -> EventType {
EventType {
name: "OrderCancelled".into(),
..event_type(true)
}
}
fn token(status: TokenStatus, event_type: EventType) -> EventReadToken {
EventReadToken {
id: "reader-1".into(),
secret: "s3cr3t".into(),
status,
created_at: timestamp(0),
revoked_at: None,
event_type,
scope: None,
start_from: EventReadStartPosition::Beginning,
start_at_sequence: None,
start_at_time: None,
}
}
fn event(event_type: EventType, sequence: i64) -> Event {
Event {
bounded_context: bounded_context("orders"),
event_type,
payload: "{}".into(),
metadata: skilj_core::shared::Metadata {
r#type: "OrderPlaced".into(),
version: 1,
client_id: "test-client".into(),
created_at: timestamp(0),
correlation_id: None,
causation_id: None,
},
sequence,
tags: Vec::new(),
encryption_keys: Vec::new(),
origin: EventOrigin::DirectlyCreated,
}
}
fn timestamp(secs: i64) -> chrono::DateTime<Utc> {
Utc.timestamp_opt(secs, 0).unwrap()
}
fn checkout_lease() -> chrono::Duration {
chrono::Duration::minutes(5)
}
fn event_at(event_type: EventType, sequence: i64, created_at: chrono::DateTime<Utc>) -> Event {
Event {
metadata: skilj_core::shared::Metadata {
created_at,
..event(event_type.clone(), sequence).metadata
},
..event(event_type, sequence)
}
}
#[test]
fn read_cursor_carries_its_declared_fields() {
let t = token(TokenStatus::Active, event_type(true));
let cursor = ReadCursor {
token: t.clone(),
ack_mode: AckMode::ManualAck,
sequence: 41,
updated_at: timestamp(1000),
checked_out_at: Some(timestamp(1000)),
};
assert_eq!(cursor.token, t);
assert_eq!(cursor.ack_mode, AckMode::ManualAck);
assert_eq!(cursor.sequence, 41);
assert_eq!(cursor.updated_at, timestamp(1000));
assert_eq!(cursor.checked_out_at, Some(timestamp(1000)));
}
#[test]
fn fetch_events_succeeds_and_returns_only_matching_later_events() {
let et = event_type(true);
let t = token(TokenStatus::Active, et.clone());
let events = vec![
event(et.clone(), 1),
event(et.clone(), 2),
event(other_event_type(), 3), event(et.clone(), 4),
];
let result = event_store::fetch_events(&t, &events, &[], Some(1), None).unwrap();
assert_eq!(
result.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![2, 4]
);
}
#[test]
fn fetch_events_matches_an_event_whose_type_snapshot_is_stale() {
let then = event_type(true);
let mut now = then.clone();
now.bounded_context.status = BoundedContextStatus::Archived;
now.last_fired_at = Some(timestamp(60));
now.schema_version += 1;
let t = token(TokenStatus::Active, now.clone());
let events = vec![event(then, 0), event(now, 1)];
let result = event_store::fetch_events(&t, &events, &[], None, None).unwrap();
assert_eq!(
result.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![0, 1]
);
}
#[test]
fn fetch_events_with_no_after_sequence_starts_from_the_beginning() {
let et = event_type(true);
let t = token(TokenStatus::Active, et.clone());
let events = vec![event(et.clone(), 0), event(et.clone(), 1)];
let result = event_store::fetch_events(&t, &events, &[], None, None).unwrap();
assert_eq!(
result.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![0, 1]
);
}
#[test]
fn fetch_events_rejects_a_revoked_token() {
let t = token(TokenStatus::Revoked, event_type(true));
let err = event_store::fetch_events(&t, &[], &[], None, None).unwrap_err();
assert_eq!(err.code(), access_control::Error::TokenNotActive.code());
}
#[test]
fn fetch_events_read_type_is_always_the_tokens_event_type_by_construction() {
let et = event_type(true);
let t = token(TokenStatus::Active, et.clone());
let _ = event_store::fetch_events(&t, &[], &[], None, None);
assert_eq!(t.event_type, et);
}
#[test]
fn fetch_events_rejects_an_event_type_not_opted_into_reads() {
let t = token(TokenStatus::Active, event_type(false));
let err = event_store::fetch_events(&t, &[], &[], None, None).unwrap_err();
assert_eq!(err.code(), event_store::Error::EventReadNotAllowed.code());
}
#[test]
fn fetch_events_rejects_an_invalid_filter() {
let t = token(TokenStatus::Active, event_type(true));
let bogus_filter = vec![Filter {
field: "amount".into(),
operator: FilterOperator::GreaterThan,
value: "10".into(),
}];
let err = event_store::fetch_events(&t, &[], &bogus_filter, None, None).unwrap_err();
assert_eq!(err.code(), event_store::Error::InvalidFilter.code());
}
#[test]
fn consume_events_provisions_a_cursor_on_first_use() {
let et = event_type(true);
let t = token(TokenStatus::Active, et.clone());
let events = vec![event(et.clone(), 0), event(et.clone(), 1)];
let ConsumeEventsResult {
served,
cursor_update,
} = event_store::consume_events(
&t,
None,
Some(AckMode::AutoAdvance),
&events,
&[],
timestamp(100),
checkout_lease(),
)
.unwrap();
assert_eq!(
served.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![0, 1]
);
match cursor_update {
CursorUpdate::Created(cursor) => {
assert_eq!(cursor.token, t);
assert_eq!(cursor.ack_mode, AckMode::AutoAdvance);
assert_eq!(cursor.sequence, 1); assert_eq!(cursor.updated_at, timestamp(100));
assert_eq!(cursor.checked_out_at, None);
}
other => panic!("expected ReadCursor.created, got {other:?}"),
}
}
#[test]
fn consume_events_at_sequence_starts_strictly_after_the_given_sequence() {
let et = event_type(true);
let t = EventReadToken {
start_from: EventReadStartPosition::AtSequence,
start_at_sequence: Some(0),
..token(TokenStatus::Active, et.clone())
};
let events = vec![event(et.clone(), 0), event(et.clone(), 1), event(et, 2)];
let ConsumeEventsResult {
served,
cursor_update,
} = event_store::consume_events(
&t,
None,
Some(AckMode::AutoAdvance),
&events,
&[],
timestamp(100),
checkout_lease(),
)
.unwrap();
assert_eq!(
served.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![1, 2]
);
match cursor_update {
CursorUpdate::Created(cursor) => assert_eq!(cursor.sequence, 2),
other => panic!("expected ReadCursor.created, got {other:?}"),
}
}
#[test]
fn consume_events_at_time_starts_strictly_after_events_at_or_before_that_cutoff() {
let et = event_type(true);
let cutoff = timestamp(50);
let t = EventReadToken {
start_from: EventReadStartPosition::AtTime,
start_at_time: Some(cutoff),
..token(TokenStatus::Active, et.clone())
};
let events = vec![
event_at(et.clone(), 0, timestamp(10)), event_at(et.clone(), 1, timestamp(50)), event_at(et.clone(), 2, timestamp(90)), ];
let ConsumeEventsResult { served, .. } = event_store::consume_events(
&t,
None,
Some(AckMode::AutoAdvance),
&events,
&[],
timestamp(100),
checkout_lease(),
)
.unwrap();
assert_eq!(
served.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![2]
);
}
#[test]
fn consume_events_at_time_with_no_events_before_the_cutoff_serves_everything() {
let et = event_type(true);
let t = EventReadToken {
start_from: EventReadStartPosition::AtTime,
start_at_time: Some(timestamp(5)),
..token(TokenStatus::Active, et.clone())
};
let events = vec![
event_at(et.clone(), 0, timestamp(50)),
event_at(et, 1, timestamp(60)),
];
let ConsumeEventsResult { served, .. } = event_store::consume_events(
&t,
None,
Some(AckMode::AutoAdvance),
&events,
&[],
timestamp(100),
checkout_lease(),
)
.unwrap();
assert_eq!(
served.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![0, 1]
);
}
#[test]
fn consume_events_auto_advance_moves_the_cursor_to_the_highest_served_sequence() {
let et = event_type(true);
let t = token(TokenStatus::Active, et.clone());
let existing = ReadCursor {
token: t.clone(),
ack_mode: AckMode::AutoAdvance,
sequence: 5,
updated_at: timestamp(0),
checked_out_at: None,
};
let events = vec![event(et.clone(), 6), event(et.clone(), 7)];
let ConsumeEventsResult {
served,
cursor_update,
} = event_store::consume_events(
&t,
Some(&existing),
None,
&events,
&[],
timestamp(200),
checkout_lease(),
)
.unwrap();
assert_eq!(
served.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![6, 7]
);
assert_eq!(
cursor_update,
CursorUpdate::Advanced {
sequence: 7,
updated_at: timestamp(200)
}
);
}
#[test]
fn consume_events_manual_ack_never_moves_the_cursor_on_serve() {
let et = event_type(true);
let t = token(TokenStatus::Active, et.clone());
let existing = ReadCursor {
token: t.clone(),
ack_mode: AckMode::ManualAck,
sequence: 5,
updated_at: timestamp(0),
checked_out_at: None,
};
let events = vec![event(et.clone(), 6)];
let ConsumeEventsResult {
served,
cursor_update,
} = event_store::consume_events(
&t,
Some(&existing),
None,
&events,
&[],
timestamp(200),
checkout_lease(),
)
.unwrap();
assert_eq!(
served.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![6]
);
assert_eq!(
cursor_update,
CursorUpdate::Claimed {
checked_out_at: timestamp(200)
}
);
}
#[test]
fn consume_events_manual_ack_with_a_live_claim_serves_nothing() {
let et = event_type(true);
let t = token(TokenStatus::Active, et.clone());
let existing = ReadCursor {
token: t.clone(),
ack_mode: AckMode::ManualAck,
sequence: 5,
updated_at: timestamp(0),
checked_out_at: Some(timestamp(150)),
};
let events = vec![event(et, 6)];
let ConsumeEventsResult {
served,
cursor_update,
} = event_store::consume_events(
&t,
Some(&existing),
None,
&events,
&[],
timestamp(200),
checkout_lease(),
)
.unwrap();
assert!(
served.is_empty(),
"a second caller must not be served the same unacknowledged batch"
);
assert_eq!(cursor_update, CursorUpdate::Unchanged);
}
#[test]
fn consume_events_manual_ack_reclaims_a_stale_claim() {
let et = event_type(true);
let t = token(TokenStatus::Active, et.clone());
let existing = ReadCursor {
token: t.clone(),
ack_mode: AckMode::ManualAck,
sequence: 5,
updated_at: timestamp(0),
checked_out_at: Some(timestamp(200 - 6 * 60)),
};
let events = vec![event(et, 6)];
let ConsumeEventsResult {
served,
cursor_update,
} = event_store::consume_events(
&t,
Some(&existing),
None,
&events,
&[],
timestamp(200),
checkout_lease(),
)
.unwrap();
assert_eq!(
served.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![6],
"a stale claim must be reclaimed, not treated as still live"
);
assert_eq!(
cursor_update,
CursorUpdate::Claimed {
checked_out_at: timestamp(200)
}
);
}
#[test]
fn consume_events_manual_ack_provisions_a_claimed_cursor_on_first_use() {
let et = event_type(true);
let t = token(TokenStatus::Active, et.clone());
let events = vec![event(et, 0)];
let ConsumeEventsResult { cursor_update, .. } = event_store::consume_events(
&t,
None,
Some(AckMode::ManualAck),
&events,
&[],
timestamp(100),
checkout_lease(),
)
.unwrap();
match cursor_update {
CursorUpdate::Created(cursor) => {
assert_eq!(cursor.ack_mode, AckMode::ManualAck);
assert_eq!(cursor.checked_out_at, Some(timestamp(100)));
}
other => panic!("expected ReadCursor.created, got {other:?}"),
}
}
#[test]
fn consume_events_auto_advance_with_nothing_new_still_reports_advanced_at_the_same_sequence() {
let et = event_type(true);
let t = token(TokenStatus::Active, et.clone());
let existing = ReadCursor {
token: t.clone(),
ack_mode: AckMode::AutoAdvance,
sequence: 5,
updated_at: timestamp(0),
checked_out_at: None,
};
let ConsumeEventsResult {
served,
cursor_update,
} = event_store::consume_events(
&t,
Some(&existing),
None,
&[],
&[],
timestamp(200),
checkout_lease(),
)
.unwrap();
assert!(served.is_empty());
assert_eq!(
cursor_update,
CursorUpdate::Advanced {
sequence: 5,
updated_at: timestamp(200)
}
);
}
#[test]
fn consume_events_rejects_a_revoked_token() {
let t = token(TokenStatus::Revoked, event_type(true));
let err = event_store::consume_events(
&t,
None,
Some(AckMode::AutoAdvance),
&[],
&[],
timestamp(0),
checkout_lease(),
)
.unwrap_err();
assert_eq!(err.code(), access_control::Error::TokenNotActive.code());
}
#[test]
fn consume_events_rejects_an_event_type_not_opted_into_reads() {
let t = token(TokenStatus::Active, event_type(false));
let err = event_store::consume_events(
&t,
None,
Some(AckMode::AutoAdvance),
&[],
&[],
timestamp(0),
checkout_lease(),
)
.unwrap_err();
assert_eq!(err.code(), event_store::Error::EventReadNotAllowed.code());
}
#[test]
fn consume_events_rejects_an_invalid_filter() {
let t = token(TokenStatus::Active, event_type(true));
let bogus_filter = vec![Filter {
field: "amount".into(),
operator: FilterOperator::GreaterThan,
value: "10".into(),
}];
let err = event_store::consume_events(
&t,
None,
Some(AckMode::AutoAdvance),
&[],
&bogus_filter,
timestamp(0),
checkout_lease(),
)
.unwrap_err();
assert_eq!(err.code(), event_store::Error::InvalidFilter.code());
}
#[test]
fn consume_events_rejects_a_first_call_with_no_ack_mode() {
let t = token(TokenStatus::Active, event_type(true));
let err = event_store::consume_events(&t, None, None, &[], &[], timestamp(0), checkout_lease())
.unwrap_err();
assert_eq!(err.code(), event_store::Error::CursorAckModeMismatch.code());
}
#[test]
fn consume_events_rejects_an_ack_mode_that_disagrees_with_the_stored_one() {
let t = token(TokenStatus::Active, event_type(true));
let existing = ReadCursor {
token: t.clone(),
ack_mode: AckMode::ManualAck,
sequence: 0,
updated_at: timestamp(0),
checked_out_at: None,
};
let err = event_store::consume_events(
&t,
Some(&existing),
Some(AckMode::AutoAdvance),
&[],
&[],
timestamp(0),
checkout_lease(),
)
.unwrap_err();
assert_eq!(err.code(), event_store::Error::CursorAckModeMismatch.code());
}
#[test]
fn acknowledge_events_moves_the_cursor_forward() {
let t = token(TokenStatus::Active, event_type(true));
let cursor = ReadCursor {
token: t.clone(),
ack_mode: AckMode::ManualAck,
sequence: 5,
updated_at: timestamp(0),
checked_out_at: None,
};
let (sequence, updated_at) =
event_store::acknowledge_events(&t, Some(&cursor), 9, timestamp(300)).unwrap();
assert_eq!(sequence, 9);
assert_eq!(updated_at, timestamp(300));
}
#[test]
fn acknowledge_events_allows_reconfirming_ground_already_covered() {
let t = token(TokenStatus::Active, event_type(true));
let cursor = ReadCursor {
token: t.clone(),
ack_mode: AckMode::ManualAck,
sequence: 5,
updated_at: timestamp(0),
checked_out_at: None,
};
let (sequence, _) =
event_store::acknowledge_events(&t, Some(&cursor), 5, timestamp(300)).unwrap();
assert_eq!(sequence, 5);
}
#[test]
fn acknowledge_events_rejects_a_revoked_token() {
let t = token(TokenStatus::Revoked, event_type(true));
let cursor = ReadCursor {
token: t.clone(),
ack_mode: AckMode::ManualAck,
sequence: 0,
updated_at: timestamp(0),
checked_out_at: None,
};
let err = event_store::acknowledge_events(&t, Some(&cursor), 0, timestamp(0)).unwrap_err();
assert_eq!(err.code(), access_control::Error::TokenNotActive.code());
}
#[test]
fn acknowledge_events_rejects_a_token_with_no_cursor_yet() {
let t = token(TokenStatus::Active, event_type(true));
let err = event_store::acknowledge_events(&t, None, 0, timestamp(0)).unwrap_err();
assert_eq!(err.code(), event_store::Error::NoReadCursor.code());
}
#[test]
fn acknowledge_events_rejects_an_auto_advance_cursor() {
let t = token(TokenStatus::Active, event_type(true));
let cursor = ReadCursor {
token: t.clone(),
ack_mode: AckMode::AutoAdvance,
sequence: 0,
updated_at: timestamp(0),
checked_out_at: None,
};
let err = event_store::acknowledge_events(&t, Some(&cursor), 0, timestamp(0)).unwrap_err();
assert_eq!(err.code(), event_store::Error::NotManualAckCursor.code());
}
#[test]
fn acknowledge_events_rejects_moving_the_cursor_backwards() {
let t = token(TokenStatus::Active, event_type(true));
let cursor = ReadCursor {
token: t.clone(),
ack_mode: AckMode::ManualAck,
sequence: 5,
updated_at: timestamp(0),
checked_out_at: None,
};
let err = event_store::acknowledge_events(&t, Some(&cursor), 4, timestamp(0)).unwrap_err();
assert_eq!(
err.code(),
event_store::Error::AcknowledgementRegresses.code()
);
}
fn token_id(t: &EventReadToken) -> (String, String) {
(
t.event_type.bounded_context.name.clone(),
t.event_type.name.clone(),
)
}
proptest! {
#[test]
fn unique_read_cursor_per_token_holds_across_interleaved_consumes(
token_indices in prop::collection::vec(0..3usize, 1..30),
) {
let tokens = [
token(TokenStatus::Active, event_type(true)),
token(TokenStatus::Active, other_event_type()),
token(
TokenStatus::Active,
EventType {
bounded_context: bounded_context("billing"),
name: "InvoiceIssued".into(),
..event_type(true)
},
),
];
let mut cursors: std::collections::HashMap<(String, String), ReadCursor> = std::collections::HashMap::new();
for idx in token_indices {
let t = &tokens[idx];
let key = token_id(t);
let existing = cursors.get(&key);
let ack_mode = if existing.is_none() { Some(AckMode::AutoAdvance) } else { None };
let result = event_store::consume_events(
t,
existing,
ack_mode,
&[],
&[],
Utc::now(),
checkout_lease(),
)
.unwrap();
if let CursorUpdate::Created(cursor) = result.cursor_update {
let previous = cursors.insert(key.clone(), *cursor);
prop_assert!(previous.is_none(), "a second ReadCursor.created for a token that already has one");
}
}
prop_assert!(cursors.len() <= tokens.len());
}
}