use areev_cal::AreevFacade;
use areev_core::types::{Grain, Trigger, TriggerKind};
use areev_run::{ExecResult, HostToolExecutor};
use areev_store::Areev;
use areev_trigger::{
clock::FixedClock, Clock, EvalOptions, Evaluator, RunStarter, StartResult,
};
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
const NS: &str = "ops";
const WF: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
const T0: i64 = 1_767_225_600_000;
struct FakeConnector {
replies: Mutex<Vec<ExecResult>>,
requests: Mutex<Vec<Value>>,
}
impl FakeConnector {
fn new(replies: Vec<ExecResult>) -> Arc<Self> {
Arc::new(FakeConnector { replies: Mutex::new(replies), requests: Mutex::new(Vec::new()) })
}
}
impl HostToolExecutor for FakeConnector {
fn execute(&self, _name: &str, _hash: &str, input: &Value, _idem: &str) -> ExecResult {
self.requests.lock().unwrap().push(input.clone());
let mut r = self.replies.lock().unwrap();
if r.is_empty() {
ExecResult::Ok(json!({ "items": [] }))
} else {
r.remove(0)
}
}
}
fn ok(items: Value, cursor: Option<&str>, more: bool) -> ExecResult {
let mut v = json!({ "items": items, "more": more });
if let Some(c) = cursor {
v["cursor"] = json!(c);
}
ExecResult::Ok(v)
}
#[derive(Default)]
struct FakeStarter {
started: Mutex<Vec<String>>,
workflows: Mutex<Vec<String>>,
}
impl RunStarter for FakeStarter {
fn start(&self, workflow: &str, run_id: &str, _input: Value) -> StartResult {
let mut s = self.started.lock().unwrap();
if s.iter().any(|id| id == run_id) {
return StartResult::Duplicate;
}
s.push(run_id.to_string());
self.workflows.lock().unwrap().push(workflow.to_string());
StartResult::Started
}
}
struct Rig {
_dir: tempfile::TempDir,
facade: Arc<AreevFacade>,
clock: Arc<FixedClock>,
starter: Arc<FakeStarter>,
}
impl Rig {
fn new() -> Self {
let dir = tempfile::TempDir::new().unwrap();
let m = Areev::open(dir.path().join("m.db").to_str().unwrap()).unwrap();
Rig {
_dir: dir,
facade: Arc::new(AreevFacade::new(m)),
clock: Arc::new(FixedClock::new(T0)),
starter: Arc::new(FakeStarter::default()),
}
}
fn declare(&self, t: Trigger) -> String {
let t = t.created_at(T0).namespace(NS);
self.facade.with_store(|m| m.add(&t)).unwrap().to_hex()
}
fn evaluator(&self, connector: Option<Arc<dyn HostToolExecutor>>) -> Evaluator {
Evaluator {
facade: Arc::clone(&self.facade),
clock: Arc::clone(&self.clock) as Arc<dyn areev_trigger::Clock>,
connector,
starter: Some(Arc::clone(&self.starter) as Arc<dyn RunStarter>),
credentials: Default::default(),
ns: NS.into(),
principal: "user:test".into(),
}
}
fn state(&self, hash: &str) -> areev_store::TriggerState {
self.facade.with_store(|m| m.trigger_state(hash)).unwrap().map(|(s, _)| s).unwrap_or_default()
}
}
fn opts() -> EvalOptions {
EvalOptions { node: "node-A".into(), ..Default::default() }
}
#[test]
fn an_interval_trigger_fires_then_schedules_its_next() {
let rig = Rig::new();
let h = rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(120));
let ev = rig.evaluator(None);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.claimed, 1, "a never-evaluated trigger is due immediately");
assert_eq!(r.runs_started, 1);
assert!(r.errors.is_empty(), "{:?}", r.errors);
let st = rig.state(&h);
assert_eq!(st.next_due_at, Some(T0 + 120_000));
assert_eq!(st.last_fired_at, Some(T0));
assert!(st.claimed_by.is_none(), "the lease is released after firing");
let r2 = ev.run(&opts()).unwrap();
assert_eq!(r2.claimed, 0);
assert_eq!(r2.skipped_not_due, 1);
rig.clock.advance(120_000);
let r3 = ev.run(&opts()).unwrap();
assert_eq!(r3.claimed, 1);
}
#[test]
fn ingest_only_mode_is_still_idempotent() {
let rig = Rig::new();
rig.declare(
Trigger::new(TriggerKind::Polling, WF).connector("gmail").interval_secs(60).dedup_key("/id"),
);
let item = json!([{ "id": "dup", "payload": { "id": "m-1" } }]);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(item.clone(), Some("c2"), false),
ok(item, Some("c3"), false),
]);
let ev = Evaluator {
facade: Arc::clone(&rig.facade),
clock: Arc::clone(&rig.clock) as Arc<dyn Clock>,
connector: Some(conn as Arc<dyn HostToolExecutor>),
starter: None, credentials: Default::default(),
ns: NS.into(),
principal: "user:test".into(),
};
ev.run(&opts()).unwrap(); rig.clock.advance(60_000);
let first = ev.run(&opts()).unwrap();
rig.clock.advance(60_000);
let second = ev.run(&opts()).unwrap();
assert_eq!(first.ingested, 1);
assert_eq!(first.runs_started, 0, "nothing executes without a runtime");
assert_eq!(second.ingested, 0, "the replay must not be re-ingested");
assert_eq!(second.duplicates, 1);
}
#[test]
fn a_disabled_declaration_never_fires() {
let rig = Rig::new();
rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(60).enabled(false));
let r = rig.evaluator(None).run(&opts()).unwrap();
assert_eq!(r.claimed, 0);
assert_eq!(r.runs_started, 0);
}
#[test]
fn dry_run_reports_what_would_fire_and_touches_nothing() {
let rig = Rig::new();
let h = rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(60));
let r = rig.evaluator(None).run(&EvalOptions { dry_run: true, ..opts() }).unwrap();
assert_eq!(r.claimed, 1, "it reports the trigger as due…");
assert_eq!(r.runs_started, 0, "…without starting anything");
assert_eq!(rig.state(&h), areev_store::TriggerState::default(), "and writes no state");
}
#[test]
fn the_first_poll_seeds_the_cursor_and_fires_nothing() {
let rig = Rig::new();
let h = rig.declare(
Trigger::new(TriggerKind::Polling, WF).connector("gmail").interval_secs(60).dedup_key("/id"),
);
let conn = FakeConnector::new(vec![ok(
json!([{ "id": "old-1", "payload": { "id": "old-1" } }]),
Some("cursor-1"),
false,
)]);
let ev = rig.evaluator(Some(conn.clone() as Arc<dyn HostToolExecutor>));
let r = ev.run(&opts()).unwrap();
assert_eq!(r.runs_started, 0, "history must not be replayed on first contact");
assert_eq!(rig.state(&h).cursor.as_deref(), Some("cursor-1"), "but the position is recorded");
}
#[test]
fn items_after_the_seed_start_one_run_each() {
let rig = Rig::new();
let h = rig.declare(
Trigger::new(TriggerKind::Polling, WF).connector("gmail").interval_secs(60).dedup_key("/id"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(
json!([
{ "id": "a", "payload": { "id": "m-a" } },
{ "id": "b", "payload": { "id": "m-b" } }
]),
Some("c2"),
false,
),
]);
let ev = rig.evaluator(Some(conn.clone() as Arc<dyn HostToolExecutor>));
ev.run(&opts()).unwrap(); rig.clock.advance(60_000);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.items, 2);
assert_eq!(r.runs_started, 2);
assert_eq!(rig.state(&h).cursor.as_deref(), Some("c2"));
let reqs = conn.requests.lock().unwrap();
assert_eq!(reqs[1]["cursor"], json!("c1"));
}
#[test]
fn the_same_item_twice_starts_one_run_and_reports_a_skip() {
let rig = Rig::new();
rig.declare(
Trigger::new(TriggerKind::Polling, WF).connector("gmail").interval_secs(60).dedup_key("/id"),
);
let item = json!([{ "id": "dup", "payload": { "id": "m-1" } }]);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(item.clone(), Some("c2"), false),
ok(item, Some("c3"), false),
]);
let ev = rig.evaluator(Some(conn as Arc<dyn HostToolExecutor>));
ev.run(&opts()).unwrap(); rig.clock.advance(60_000);
let first = ev.run(&opts()).unwrap();
rig.clock.advance(60_000);
let second = ev.run(&opts()).unwrap();
assert_eq!(first.runs_started, 1);
assert_eq!(second.runs_started, 0, "the replay must not start a second run");
assert_eq!(second.duplicates, 1, "and the skip is reported, not hidden");
assert_eq!(rig.starter.started.lock().unwrap().len(), 1);
}
#[test]
fn a_backlog_drains_at_once_instead_of_waiting_out_the_interval() {
let rig = Rig::new();
let h = rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("gmail")
.interval_secs(3600) .dedup_key("/id"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(json!([{ "id": "a", "payload": { "id": "a" } }]), Some("c2"), true),
]);
let ev = rig.evaluator(Some(conn as Arc<dyn HostToolExecutor>));
ev.run(&opts()).unwrap();
rig.clock.advance(3_600_000);
let r = ev.run(&opts()).unwrap();
assert!(r.draining.contains(&h), "a connector reporting more must be flagged");
assert_eq!(rig.state(&h).next_due_at, Some(rig.clock.now_ms()), "due again immediately");
}
#[test]
fn an_item_with_no_usable_identity_is_reported_not_dropped() {
let rig = Rig::new();
rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("gmail")
.interval_secs(60)
.dedup_key("/message_id"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(json!([{ "id": "x", "payload": { "subject": "hi" } }]), Some("c2"), false),
]);
let ev = rig.evaluator(Some(conn as Arc<dyn HostToolExecutor>));
ev.run(&opts()).unwrap();
rig.clock.advance(60_000);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.runs_started, 0);
assert_eq!(r.unidentifiable, 1, "an unnameable item is a connector bug, and must show as one");
}
#[test]
fn a_due_polling_trigger_with_no_connector_fails_loudly() {
let rig = Rig::new();
rig.declare(Trigger::new(TriggerKind::Polling, WF).connector("gmail").interval_secs(60));
let r = rig.evaluator(None).run(&opts()).unwrap();
assert_eq!(r.runs_started, 0);
assert_eq!(r.errors.len(), 1);
assert!(r.errors[0].starts_with("TRG-E003"), "{}", r.errors[0]);
}
#[test]
fn a_failing_connector_backs_off_instead_of_hot_looping() {
let rig = Rig::new();
let h = rig.declare(
Trigger::new(TriggerKind::Polling, WF).connector("gmail").interval_secs(60).dedup_key("/id"),
);
let conn = FakeConnector::new(vec![ExecResult::Err {
cause: areev_run_core::FailCause::ExecutorError,
detail: "connection refused".into(),
}]);
let ev = rig.evaluator(Some(conn as Arc<dyn HostToolExecutor>));
let r = ev.run(&opts()).unwrap();
assert_eq!(r.errors.len(), 1);
assert!(r.errors[0].starts_with("TRG-E004"), "{}", r.errors[0]);
let st = rig.state(&h);
assert_eq!(st.consecutive_failures, 1);
assert!(st.last_error.is_some(), "the reason must be visible in status");
assert!(
st.next_due_at.unwrap() >= T0 + 60_000,
"backed off at least the declared interval, not retried immediately"
);
assert!(st.claimed_by.is_none(), "and the lease is released even on failure");
}
#[test]
fn one_broken_trigger_does_not_stop_the_others() {
let rig = Rig::new();
rig.declare(Trigger::new(TriggerKind::Polling, WF).connector("gmail").interval_secs(60));
rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(60));
let r = rig.evaluator(None).run(&opts()).unwrap();
assert_eq!(r.errors.len(), 1, "the polling one failed");
assert_eq!(r.runs_started, 1, "…and the interval one still fired");
}
#[test]
fn a_held_lease_is_skipped_not_stolen() {
let rig = Rig::new();
let h = rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(60));
rig.facade
.with_store(|m| {
m.put_trigger_state(
&h,
None,
&areev_store::TriggerState {
claimed_by: Some("node-B".into()),
lease_until: Some(T0 + 300_000),
..Default::default()
},
)
})
.unwrap();
let r = rig.evaluator(None).run(&opts()).unwrap();
assert_eq!(r.skipped_locked, 1, "losing a race is the steady state, not an error");
assert_eq!(r.claimed, 0);
assert!(r.errors.is_empty());
}
#[test]
fn an_expired_lease_is_reclaimed() {
let rig = Rig::new();
let h = rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(60));
rig.facade
.with_store(|m| {
m.put_trigger_state(
&h,
None,
&areev_store::TriggerState {
claimed_by: Some("node-B".into()),
lease_until: Some(T0 - 1), fence: 4,
..Default::default()
},
)
})
.unwrap();
let r = rig.evaluator(None).run(&opts()).unwrap();
assert_eq!(r.claimed, 1, "a node that died must not park a trigger forever");
assert_eq!(rig.state(&h).fence, 5, "and the fence advanced past the dead claim");
}
#[test]
fn status_reports_a_trigger_that_has_never_fired() {
let rig = Rig::new();
rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(60));
let st = rig.evaluator(None).status().unwrap();
assert_eq!(st.len(), 1);
assert!(st[0].never_fired, "an unnoticed misconfiguration must be visible");
assert!(st[0].due);
assert_eq!(st[0].leased_by, None);
}
#[test]
fn a_firing_is_journaled() {
let rig = Rig::new();
let h = rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(60));
rig.evaluator(None).run(&opts()).unwrap();
let obs = rig
.facade
.with_store(|m| {
m.recent_live_scoped(
&[areev_trigger::eval::TRIGGER_NS.to_string()],
Some(areev_core::types::GrainType::Observation),
10,
)
})
.unwrap();
assert_eq!(obs.len(), 1, "every firing leaves a record");
assert_eq!(obs[0].get_str("trigger"), Some(h.as_str()));
assert_eq!(obs[0].get_i64("runs_started"), Some(1));
}
fn memory_trigger(where_src: &str) -> Trigger {
let cond = areev_trigger::predicate::parse_predicate(where_src).unwrap();
Trigger::new(TriggerKind::Memory, WF)
.predicate(areev_trigger::predicate::to_value(&cond).unwrap())
}
#[test]
fn a_memory_trigger_seeds_at_the_head_and_ignores_history() {
let rig = Rig::new();
for i in 0..5 {
let f = areev_core::types::Fact::new(&format!("s{i}"), "mg:kind", "invoice")
.namespace(NS)
.created_at(T0);
rig.facade.with_store(|m| m.add(&f)).unwrap();
}
rig.declare(memory_trigger(r#"object = "invoice""#));
let r = rig.evaluator(None).run(&opts()).unwrap();
assert_eq!(r.runs_started, 0, "history must not fire");
assert_eq!(r.items, 0);
}
#[test]
fn a_memory_trigger_fires_for_grains_that_appear_after_it() {
let rig = Rig::new();
let h = rig.declare(memory_trigger(r#"object = "invoice""#));
let ev = rig.evaluator(None);
ev.run(&opts()).unwrap();
for (s, o) in [("a", "invoice"), ("b", "receipt"), ("c", "invoice")] {
let f = areev_core::types::Fact::new(s, "mg:kind", o).namespace(NS).created_at(T0);
rig.facade.with_store(|m| m.add(&f)).unwrap();
}
let r = ev.run(&opts()).unwrap();
assert_eq!(r.items, 2, "only the matching grains are items");
assert_eq!(r.runs_started, 2);
let again = ev.run(&opts()).unwrap();
assert_eq!(again.items, 0, "the op cursor must not re-deliver");
assert!(rig.state(&h).op_cursor.is_some());
}
#[test]
fn a_memory_trigger_uses_the_content_address_as_the_item_identity() {
let rig = Rig::new();
rig.declare(memory_trigger(r#"object = "invoice""#));
let ev = rig.evaluator(None);
ev.run(&opts()).unwrap();
let f = areev_core::types::Fact::new("a", "mg:kind", "invoice").namespace(NS).created_at(T0);
rig.facade.with_store(|m| m.add(&f)).unwrap();
let first = ev.run(&opts()).unwrap();
assert_eq!(first.runs_started, 1);
rig.facade.with_store(|m| m.add(&f)).unwrap(); let second = ev.run(&opts()).unwrap();
assert_eq!(second.runs_started, 0, "the same grain must not fire twice");
}
#[test]
fn a_memory_trigger_with_a_boolean_predicate_respects_precedence() {
let rig = Rig::new();
rig.declare(memory_trigger(
r#"(relation = "mg:kind" AND object = "invoice") OR object = "urgent""#,
));
let ev = rig.evaluator(None);
ev.run(&opts()).unwrap();
for (s, r, o) in [
("a", "mg:kind", "invoice"), ("b", "other", "urgent"), ("c", "other", "receipt"), ] {
let f = areev_core::types::Fact::new(s, r, o).namespace(NS).created_at(T0);
rig.facade.with_store(|m| m.add(&f)).unwrap();
}
let out = ev.run(&opts()).unwrap();
assert_eq!(out.items, 2, "both branches match, the third does not");
}
fn manual_member() -> Trigger {
Trigger::new(TriggerKind::Interval, WF).interval_secs(1)
}
#[test]
fn an_and_gate_waits_for_every_member() {
let rig = Rig::new();
let a = rig.declare(manual_member());
let b = rig.declare(manual_member());
let cond = areev_trigger::predicate::parse_predicate("invoice = true AND po = true").unwrap();
let composite = rig.declare(
Trigger::new(TriggerKind::Composite, WF)
.member("invoice", &a)
.member("po", &b)
.predicate(areev_trigger::predicate::to_value(&cond).unwrap()),
);
let r = rig.evaluator(None).run(&opts()).unwrap();
assert!(r.errors.is_empty(), "{:?}", r.errors);
let st = rig.state(&composite);
assert!(st.last_fired_at.is_some(), "an AND gate with both members must fire");
}
#[test]
fn a_gate_naming_an_undeclared_member_is_refused() {
let rig = Rig::new();
let a = rig.declare(manual_member());
let b = rig.declare(manual_member());
let cond =
areev_trigger::predicate::parse_predicate("invoice = true AND ghost = true").unwrap();
rig.declare(
Trigger::new(TriggerKind::Composite, WF)
.member("invoice", &a)
.member("po", &b)
.predicate(areev_trigger::predicate::to_value(&cond).unwrap()),
);
let r = rig.evaluator(None).run(&opts()).unwrap();
assert!(
r.errors.iter().any(|e| e.contains("TRG-E008")),
"expected TRG-E008, got {:?}",
r.errors
);
}
#[test]
fn a_partial_match_expires_with_its_window() {
let rig = Rig::new();
let a = rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(3600));
let b = rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(86_400));
let cond = areev_trigger::predicate::parse_predicate("slow = true AND fast = true").unwrap();
let composite = rig.declare(
Trigger::new(TriggerKind::Composite, WF)
.member("fast", &a)
.member("slow", &b)
.predicate(areev_trigger::predicate::to_value(&cond).unwrap())
.window_ms(60_000), );
let ev = rig.evaluator(None);
ev.run(&opts()).unwrap(); let fired_once = rig.state(&composite).last_fired_at;
assert!(fired_once.is_some());
rig.clock.advance(3_600_000);
ev.run(&opts()).unwrap();
let st = rig.state(&composite);
assert!(
st.partials.values().all(|p| rig.clock.now_ms() - p.started_ms <= 60_000),
"stale partials must be pruned, found {:?}",
st.partials
);
}
#[test]
fn correlation_keeps_unrelated_work_apart() {
let rig = Rig::new();
let a = rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("src-a")
.interval_secs(1)
.dedup_key("/id"),
);
let cond = areev_trigger::predicate::parse_predicate("src = true").unwrap();
let composite = rig.declare(
Trigger::new(TriggerKind::Composite, WF)
.member("src", &a)
.predicate(areev_trigger::predicate::to_value(&cond).unwrap())
.correlate("/thread"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(
json!([
{ "id": "1", "payload": { "id": "1", "thread": "T1" } },
{ "id": "2", "payload": { "id": "2", "thread": "T2" } }
]),
Some("c2"),
false,
),
]);
let ev = rig.evaluator(Some(conn as Arc<dyn HostToolExecutor>));
ev.run(&opts()).unwrap(); rig.clock.advance(1_000);
ev.run(&opts()).unwrap();
let st = rig.state(&composite);
let keys: Vec<_> = st.partials.keys().cloned().collect();
assert!(
keys.contains(&"T1".to_string()) || st.last_fired_at.is_some(),
"correlation values become distinct partial keys, got {keys:?}"
);
}
#[test]
fn a_once_trigger_fires_exactly_once_and_then_never_again() {
let rig = Rig::new();
let h = rig.declare(Trigger::new(TriggerKind::Once, WF).at_ms(T0 + 1_000));
let ev = rig.evaluator(None);
let early = ev.run(&opts()).unwrap();
assert_eq!(early.claimed, 0, "a once trigger must wait for its instant");
rig.clock.advance(1_000);
let fired = ev.run(&opts()).unwrap();
assert_eq!(fired.claimed, 1);
assert_eq!(fired.runs_started, 1);
for step in 0..5 {
rig.clock.advance(60_000);
let again = ev.run(&opts()).unwrap();
assert_eq!(again.claimed, 0, "re-fired on pass {step}");
assert_eq!(again.runs_started, 0, "re-fired on pass {step}");
}
assert_eq!(rig.state(&h).last_fired_at, Some(T0 + 1_000), "only the first firing counts");
}
#[test]
fn a_cron_trigger_waits_for_its_boundary_rather_than_firing_on_declaration() {
let rig = Rig::new(); let h = rig.declare(Trigger::new(TriggerKind::Schedule, WF).cron("0 9 * * *"));
let ev = rig.evaluator(None);
let early = ev.run(&opts()).unwrap();
assert_eq!(early.claimed, 0, "midnight is not 9am");
rig.clock.advance(9 * 3_600_000);
let fired = ev.run(&opts()).unwrap();
assert_eq!(fired.claimed, 1, "should fire on the boundary");
assert!(!rig.state(&h).exhausted, "a recurring schedule is never exhausted");
rig.clock.advance(3_600_000);
assert_eq!(ev.run(&opts()).unwrap().claimed, 0);
}
#[test]
fn an_interval_trigger_still_fires_immediately_on_first_evaluation() {
let rig = Rig::new();
rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(3600));
assert_eq!(rig.evaluator(None).run(&opts()).unwrap().claimed, 1);
}
#[test]
fn display_helpers_do_not_panic_on_a_short_or_non_ascii_workflow() {
let rig = Rig::new();
for wf in ["ab", "", "日本語のワークフロー"] {
let t = Trigger::new(TriggerKind::Interval, WF).interval_secs(60).created_at(T0).namespace(NS);
let mut t = t;
t.workflow = wf.to_string();
let _ = rig.facade.with_store(|m| m.add(&t));
}
let status = rig.evaluator(None).status().unwrap();
assert!(!status.is_empty());
}
fn firings(rig: &Rig) -> Vec<areev_core::format::deserialize::DeserializedGrain> {
rig.facade
.with_store(|m| {
m.recent_live_scoped(
&[areev_trigger::eval::TRIGGER_NS.to_string()],
Some(areev_core::types::GrainType::Observation),
10,
)
})
.unwrap()
}
#[test]
fn a_firing_that_identified_nothing_records_why() {
let rig = Rig::new();
rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("gmail")
.interval_secs(60)
.dedup_key("/message_id"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(
json!([
{ "id": "a", "payload": { "subject": "one" } },
{ "id": "b", "payload": { "subject": "two" } }
]),
Some("c2"),
false,
),
]);
let ev = rig.evaluator(Some(conn as Arc<dyn HostToolExecutor>));
ev.run(&opts()).unwrap();
rig.clock.advance(60_000);
ev.run(&opts()).unwrap();
let obs = firings(&rig);
let latest = &obs[0];
assert_eq!(latest.get_i64("items"), Some(2));
assert_eq!(latest.get_i64("runs_started"), Some(0));
assert_eq!(
latest.get_i64("unidentifiable"),
Some(2),
"the record must say why two items produced no runs"
);
}
#[test]
fn an_ordinary_firing_does_not_carry_the_diagnostic_counts() {
let rig = Rig::new();
rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(60));
rig.evaluator(None).run(&opts()).unwrap();
let obs = firings(&rig);
assert_eq!(obs[0].get_i64("runs_started"), Some(1));
assert_eq!(obs[0].get_i64("unidentifiable"), None);
assert_eq!(obs[0].get_i64("ingested"), None);
}
#[test]
fn a_delivery_that_names_nothing_is_still_journaled() {
let rig = Rig::new();
let h = rig.declare(Trigger::new(TriggerKind::Webhook, WF).dedup_key("/message_id"));
let ev = rig.evaluator(None);
let report = ev.deliver(&h, json!({ "subject": "no message_id here" })).unwrap();
assert_eq!(report.runs_started, 0);
assert_eq!(report.unidentifiable, 1);
let obs = firings(&rig);
assert_eq!(obs.len(), 1, "an unusable delivery still has to leave a record");
assert_eq!(obs[0].get_str("trigger"), Some(h.as_str()));
assert_eq!(obs[0].get_i64("unidentifiable"), Some(1));
assert_eq!(obs[0].get_str("note"), Some("delivered"));
}
#[test]
fn a_composite_naming_an_undeclared_member_is_refused_at_declaration() {
let cond =
areev_trigger::predicate::parse_predicate("invoice = true AND ghost = true").unwrap();
let t = Trigger::new(TriggerKind::Composite, WF)
.member("invoice", WF)
.member("purchase_order", WF)
.predicate(areev_trigger::predicate::to_value(&cond).unwrap());
let err = areev_trigger::schedule::validate(&t).unwrap_err();
assert_eq!(err.code(), "TRG-E008");
assert!(err.to_string().contains("ghost"), "{err}");
let ok_cond =
areev_trigger::predicate::parse_predicate("invoice = true AND purchase_order = true")
.unwrap();
let good = Trigger::new(TriggerKind::Composite, WF)
.member("invoice", WF)
.member("purchase_order", WF)
.predicate(areev_trigger::predicate::to_value(&ok_cond).unwrap());
assert!(areev_trigger::schedule::validate(&good).is_ok());
}
#[test]
fn an_unusable_declaration_is_reported_distinctly_not_as_waiting() {
let rig = Rig::new();
let bad = rig.declare(
Trigger::new(TriggerKind::Schedule, WF)
.cron("0 9 * * *")
.config(json!({ "int:timezone": "Asia/Kolkata" })),
);
let good = rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(60));
let ev = rig.evaluator(None);
let status = ev.status().unwrap();
let bad_row = status.iter().find(|s| s.trigger == bad).unwrap();
let why = bad_row.unusable.as_deref().expect("the reason must be reported, not inferred");
assert!(why.contains("TRG-E006"), "the reason must name the code: {why}");
assert!(!bad_row.due, "an unusable declaration must never be reported as due");
let good_row = status.iter().find(|s| s.trigger == good).unwrap();
assert!(good_row.unusable.is_none());
assert!(good_row.due);
let report = ev.run(&opts()).unwrap();
assert_eq!(report.unusable, 1, "an unusable trigger must not hide in not-due");
assert_eq!(report.claimed, 1, "only the healthy trigger fires");
assert!(
report.errors.iter().any(|e| e.contains("TRG-E006")),
"the reason must reach the operator: {:?}",
report.errors
);
}
#[derive(Default)]
struct CapturingStarter {
inputs: Mutex<Vec<Value>>,
run_ids: Mutex<Vec<String>>,
}
impl RunStarter for CapturingStarter {
fn start(&self, _workflow: &str, run_id: &str, input: Value) -> StartResult {
self.inputs.lock().unwrap().push(input);
self.run_ids.lock().unwrap().push(run_id.to_string());
StartResult::Started
}
}
#[test]
fn a_context_query_trigger_carries_assembled_context() {
let rig = Rig::new();
rig.facade
.with_store(|m| {
m.add(
&areev_core::types::Fact::new("acme", "deploy_target", "k8s")
.namespace(NS)
.created_at(T0),
)
})
.unwrap();
let ex = areev_cal::CalExecutor::new(areev_cal::CalExecutorConfig::default());
ex.execute(
r#"DEFINE QUERY "triage_ctx"() AS { RECALL facts WHERE subject = "acme" AND namespace = "ops" }"#,
rig.facade.as_ref(),
)
.expect("DEFINE QUERY must succeed");
let h = rig.declare(
Trigger::new(TriggerKind::Interval, WF).interval_secs(120).context_query("triage_ctx"),
);
let starter = Arc::new(CapturingStarter::default());
let mut ev = rig.evaluator(None);
ev.starter = Some(Arc::clone(&starter) as Arc<dyn RunStarter>);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.runs_started, 1, "errors: {:?}", r.errors);
let inputs = starter.inputs.lock().unwrap();
let ctx = &inputs[0]["context"];
assert!(
ctx.to_string().contains("deploy_target"),
"the run input must carry the assembled context: {}",
inputs[0]
);
assert_eq!(inputs[0]["trigger"], json!(h), "the declared payload survives beside it");
}
#[test]
fn a_missing_context_query_refuses_the_firing() {
let rig = Rig::new();
rig.declare(
Trigger::new(TriggerKind::Interval, WF).interval_secs(120).context_query("not_defined"),
);
let starter = Arc::new(CapturingStarter::default());
let mut ev = rig.evaluator(None);
ev.starter = Some(Arc::clone(&starter) as Arc<dyn RunStarter>);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.runs_started, 0, "a declared-context trigger must not fire blind");
assert!(
r.errors.iter().any(|e| e.contains("not_defined")),
"the refusal names the query: {:?}",
r.errors
);
assert!(starter.inputs.lock().unwrap().is_empty(), "no run input was handed over");
}
#[test]
fn a_context_query_binds_parameters_from_the_firing_item() {
let rig = Rig::new();
rig.facade
.with_store(|m| {
m.add(
&areev_core::types::Fact::new("sess-42", "corrects", "invoice-7")
.namespace(NS)
.created_at(T0),
)?;
m.add(
&areev_core::types::Fact::new("sess-99", "corrects", "invoice-9")
.namespace(NS)
.created_at(T0),
)
})
.unwrap();
let ex = areev_cal::CalExecutor::new(areev_cal::CalExecutorConfig::default());
ex.execute(
r#"DEFINE QUERY "thread_ctx"($session) AS { RECALL facts WHERE subject = $session AND namespace = "ops" }"#,
rig.facade.as_ref(),
)
.expect("DEFINE QUERY must succeed");
rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("gmail")
.interval_secs(60)
.dedup_key("/id")
.context_query("thread_ctx($session = /session)"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(
json!([{ "id": "m-1", "payload": { "id": "m-1", "session": "sess-42" } }]),
Some("c2"),
false,
),
]);
let starter = Arc::new(CapturingStarter::default());
let ev = Evaluator {
facade: Arc::clone(&rig.facade),
clock: Arc::clone(&rig.clock) as Arc<dyn Clock>,
connector: Some(conn as Arc<dyn HostToolExecutor>),
starter: Some(Arc::clone(&starter) as Arc<dyn RunStarter>),
credentials: Default::default(),
ns: NS.into(),
principal: "user:test".into(),
};
ev.run(&opts()).unwrap(); rig.clock.advance(60_000);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.runs_started, 1, "errors: {:?}", r.errors);
let inputs = starter.inputs.lock().unwrap();
let ctx = inputs[0]["context"].to_string();
assert!(
ctx.contains("invoice-7"),
"the bound thread's fact must be in the context: {}",
inputs[0]
);
assert!(
!ctx.contains("invoice-9"),
"the decoy thread must be EXCLUDED — the binding scoped the query: {}",
inputs[0]
);
}
#[test]
fn an_unresolvable_context_pointer_refuses_the_firing() {
let rig = Rig::new();
let ex = areev_cal::CalExecutor::new(areev_cal::CalExecutorConfig::default());
ex.execute(
r#"DEFINE QUERY "thread_ctx"($session) AS { RECALL facts WHERE subject = $session AND namespace = "ops" }"#,
rig.facade.as_ref(),
)
.unwrap();
rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("gmail")
.interval_secs(60)
.dedup_key("/id")
.context_query("thread_ctx($session = /session)"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(json!([{ "id": "m-1", "payload": { "id": "m-1" } }]), Some("c2"), false),
]);
let starter = Arc::new(CapturingStarter::default());
let ev = Evaluator {
facade: Arc::clone(&rig.facade),
clock: Arc::clone(&rig.clock) as Arc<dyn Clock>,
connector: Some(conn as Arc<dyn HostToolExecutor>),
starter: Some(Arc::clone(&starter) as Arc<dyn RunStarter>),
credentials: Default::default(),
ns: NS.into(),
principal: "user:test".into(),
};
ev.run(&opts()).unwrap();
rig.clock.advance(60_000);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.runs_started, 0, "a run must not start with a hole in its context");
assert!(
r.errors.iter().any(|e| e.contains("/session")),
"the refusal names the pointer: {:?}",
r.errors
);
assert!(starter.inputs.lock().unwrap().is_empty());
}
#[test]
fn a_non_scalar_context_binding_refuses_the_firing() {
let rig = Rig::new();
let ex = areev_cal::CalExecutor::new(areev_cal::CalExecutorConfig::default());
ex.execute(
r#"DEFINE QUERY "thread_ctx"($session) AS { RECALL facts WHERE subject = $session AND namespace = "ops" }"#,
rig.facade.as_ref(),
)
.unwrap();
rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("gmail")
.interval_secs(60)
.dedup_key("/id")
.context_query("thread_ctx($session = /email)"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(
json!([{ "id": "m-1", "payload": { "id": "m-1", "email": { "from": "a@b.c" } } }]),
Some("c2"),
false,
),
]);
let starter = Arc::new(CapturingStarter::default());
let ev = Evaluator {
facade: Arc::clone(&rig.facade),
clock: Arc::clone(&rig.clock) as Arc<dyn Clock>,
connector: Some(conn as Arc<dyn HostToolExecutor>),
starter: Some(Arc::clone(&starter) as Arc<dyn RunStarter>),
credentials: Default::default(),
ns: NS.into(),
principal: "user:test".into(),
};
ev.run(&opts()).unwrap();
rig.clock.advance(60_000);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.runs_started, 0);
assert!(
r.errors.iter().any(|e| e.contains("scalar")),
"the refusal says why: {:?}",
r.errors
);
}
#[test]
fn a_malformed_context_query_spelling_is_refused_at_declaration() {
let t = Trigger::new(TriggerKind::Interval, WF)
.interval_secs(60)
.context_query("ctx($session = session)"); let err = areev_trigger::schedule::validate(&t).expect_err("must refuse");
assert!(err.to_string().contains("context_query"), "{err}");
}
#[test]
fn connector_blobs_land_in_the_cas_and_the_payload_is_rewritten() {
let rig = Rig::new();
let h = rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("gmail")
.interval_secs(60)
.dedup_key("/id"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(
json!([{
"id": "m-1",
"payload": {
"id": "m-1",
"attachments": [{ "filename": "inv.pdf", "blob": "@0" }],
},
"blobs": [{ "filename": "inv.pdf", "mime": "application/pdf", "b64": "Zm9vYmFy" }],
}]),
Some("c2"),
false,
),
]);
let starter = Arc::new(CapturingStarter::default());
let ev = Evaluator {
facade: Arc::clone(&rig.facade),
clock: Arc::clone(&rig.clock) as Arc<dyn Clock>,
connector: Some(conn as Arc<dyn HostToolExecutor>),
starter: Some(Arc::clone(&starter) as Arc<dyn RunStarter>),
credentials: Default::default(),
ns: NS.into(),
principal: "user:test".into(),
};
ev.run(&opts()).unwrap();
rig.clock.advance(60_000);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.runs_started, 1, "errors: {:?}", r.errors);
let inputs = starter.inputs.lock().unwrap();
let uri = inputs[0]["item"]["attachments"][0]["blob"]
.as_str()
.expect("blob ref must be a string")
.to_string();
assert!(uri.starts_with("cas://sha256:"), "rewritten to an address: {uri}");
assert!(
!inputs[0].to_string().contains("Zm9vYmFy"),
"the base64 must not reach the run input"
);
let bytes = rig.facade.with_store(|m| m.get_blob(&uri)).unwrap();
assert_eq!(bytes, b"foobar");
let run_id = starter.run_ids.lock().unwrap()[0].clone();
let grains = rig.facade.with_store(|m| m.run_grains(NS, &run_id, 0, 10)).unwrap();
assert!(
grains.iter().any(|(_, g)| format!("{:?}", g.fields).contains(&uri)),
"the ingest Event must carry a content_ref to {uri}; got {} grain(s): {h}",
grains.len()
);
}
#[test]
fn an_over_budget_blob_refuses_the_whole_poll_and_keeps_the_cursor() {
let rig = Rig::new();
let h = rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("gmail")
.interval_secs(60)
.dedup_key("/id"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(
json!([{
"id": "m-1",
"payload": { "id": "m-1", "a": { "blob": "@0" } },
"blobs": [{ "filename": "big.bin", "b64": "Zm9vYmFy" }],
}]),
Some("c2"),
false,
),
]);
let starter = Arc::new(CapturingStarter::default());
let ev = Evaluator {
facade: Arc::clone(&rig.facade),
clock: Arc::clone(&rig.clock) as Arc<dyn Clock>,
connector: Some(conn as Arc<dyn HostToolExecutor>),
starter: Some(Arc::clone(&starter) as Arc<dyn RunStarter>),
credentials: Default::default(),
ns: NS.into(),
principal: "user:test".into(),
};
ev.run(&opts()).unwrap(); rig.clock.advance(60_000);
let tight = EvalOptions { max_blob_bytes_per_item: 3, ..opts() };
let r = ev.run(&tight).unwrap();
assert_eq!(r.runs_started, 0);
assert!(
r.errors.iter().any(|e| e.contains("TRG-E011")),
"the refusal carries its code: {:?}",
r.errors
);
let st = rig.state(&h);
assert_eq!(st.cursor.as_deref(), Some("c1"), "the cursor must not advance past evidence");
assert!(st.consecutive_failures > 0, "the failure is counted for backoff");
assert!(starter.inputs.lock().unwrap().is_empty(), "no run started");
}
#[test]
fn a_dangling_blob_reference_refuses_the_poll() {
let rig = Rig::new();
let h = rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("gmail")
.interval_secs(60)
.dedup_key("/id"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(
json!([{
"id": "m-1",
"payload": { "id": "m-1", "a": { "blob": "@3" } },
"blobs": [{ "b64": "Zm9vYmFy" }],
}]),
Some("c2"),
false,
),
]);
let ev = rig_polling_evaluator(&rig, conn);
ev.run(&opts()).unwrap();
rig.clock.advance(60_000);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.runs_started, 0);
assert!(r.errors.iter().any(|e| e.contains("TRG-E011")), "{:?}", r.errors);
assert_eq!(rig.state(&h).cursor.as_deref(), Some("c1"));
}
#[test]
fn undecodable_blob_base64_refuses_the_poll() {
let rig = Rig::new();
rig.declare(
Trigger::new(TriggerKind::Polling, WF)
.connector("gmail")
.interval_secs(60)
.dedup_key("/id"),
);
let conn = FakeConnector::new(vec![
ok(json!([]), Some("c1"), false),
ok(
json!([{
"id": "m-1",
"payload": { "id": "m-1" },
"blobs": [{ "filename": "x", "b64": "!!! not base64 !!!" }],
}]),
Some("c2"),
false,
),
]);
let ev = rig_polling_evaluator(&rig, conn);
ev.run(&opts()).unwrap();
rig.clock.advance(60_000);
let r = ev.run(&opts()).unwrap();
assert_eq!(r.runs_started, 0);
assert!(r.errors.iter().any(|e| e.contains("TRG-E011")), "{:?}", r.errors);
}
fn rig_polling_evaluator(rig: &Rig, conn: Arc<FakeConnector>) -> Evaluator {
Evaluator {
facade: Arc::clone(&rig.facade),
clock: Arc::clone(&rig.clock) as Arc<dyn Clock>,
connector: Some(conn as Arc<dyn HostToolExecutor>),
starter: None,
credentials: Default::default(),
ns: NS.into(),
principal: "user:test".into(),
}
}
#[test]
fn a_prefixed_workflow_reference_still_starts_its_run() {
let rig = Rig::new();
rig.declare(Trigger::new(TriggerKind::Interval, &format!("sha256:{WF}")).interval_secs(60));
let r = rig.evaluator(None).run(&opts()).unwrap();
assert_eq!(r.runs_started, 1, "{:?}", r.errors);
assert!(r.errors.is_empty(), "{:?}", r.errors);
assert_eq!(
rig.starter.workflows.lock().unwrap().as_slice(),
&[WF.to_string()],
"the starter must be handed the bare address, whatever the declaration spelled"
);
}
#[test]
fn a_grain_scheme_prefix_is_read_through_too() {
let rig = Rig::new();
rig.declare(
Trigger::new(TriggerKind::Interval, &format!("grain:sha256:{WF}")).interval_secs(60),
);
let r = rig.evaluator(None).run(&opts()).unwrap();
assert_eq!(r.runs_started, 1, "{:?}", r.errors);
assert_eq!(rig.starter.workflows.lock().unwrap().as_slice(), &[WF.to_string()]);
}
#[test]
fn an_undecodable_workflow_reference_is_unusable_not_waiting() {
let rig = Rig::new();
let bad = rig.declare(Trigger::new(TriggerKind::Interval, "not-a-hash").interval_secs(60));
let ev = rig.evaluator(None);
let status = ev.status().unwrap();
let row = status.iter().find(|s| s.trigger == bad).unwrap();
let why = row.unusable.as_deref().expect("the reason must be reported, not inferred");
assert!(why.contains("TRG-E002"), "the reason must name the code: {why}");
assert!(!row.due, "an unusable declaration must never be reported as due");
let report = ev.run(&opts()).unwrap();
assert_eq!(report.runs_started, 0);
assert_eq!(report.unusable, 1);
}
#[test]
fn a_declarations_name_reaches_status_and_is_not_invented_when_absent() {
use areev_core::types::Grain as _;
let rig = Rig::new();
let named = rig.declare(
Trigger::new(TriggerKind::Interval, WF)
.interval_secs(60)
.extra_field("name", json!("nightly-invoice-sweep")),
);
let blank = rig.declare(
Trigger::new(TriggerKind::Interval, WF)
.interval_secs(90)
.extra_field("name", json!(" ")),
);
let unnamed = rig.declare(Trigger::new(TriggerKind::Interval, WF).interval_secs(120));
let status = rig.evaluator(None).status().unwrap();
let name_of = |h: &str| {
status.iter().find(|s| s.trigger == h).unwrap().name.clone()
};
assert_eq!(name_of(&named).as_deref(), Some("nightly-invoice-sweep"));
assert_eq!(name_of(&blank), None);
assert_eq!(name_of(&unnamed), None);
}