use dataflow_rs::engine::message::Message;
use dataflow_rs::{Engine, ExecutionStep, Workflow};
use serde_json::json;
mod common;
use common::{LoggingTask, dv, n_map_task_workflow};
fn split_pair_sync() -> Vec<Workflow> {
vec![
Workflow::from_json(
r#"{ "id": "lower", "name": "lower", "priority": 0, "condition": true,
"rollout": { "bucket_start": 0, "bucket_end": 50 },
"tasks": [ { "id": "lo", "name": "lo", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.side", "logic": "lower" } ] } } } ] }"#,
)
.unwrap(),
Workflow::from_json(
r#"{ "id": "upper", "name": "upper", "priority": 1, "condition": true,
"rollout": { "bucket_start": 50, "bucket_end": 100 },
"tasks": [ { "id": "hi", "name": "hi", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.side", "logic": "upper" } ] } } } ] }"#,
)
.unwrap(),
]
}
fn split_pair_async() -> Vec<Workflow> {
vec![
Workflow::from_json(
r#"{ "id": "lower", "name": "lower", "priority": 0, "condition": true,
"rollout": { "bucket_start": 0, "bucket_end": 50 },
"tasks": [
{ "id": "lo_map", "name": "lo_map", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.side", "logic": "lower" } ] } } },
{ "id": "lo_async", "name": "lo_async", "function": {
"name": "logger", "input": {} } } ] }"#,
)
.unwrap(),
Workflow::from_json(
r#"{ "id": "upper", "name": "upper", "priority": 1, "condition": true,
"rollout": { "bucket_start": 50, "bucket_end": 100 },
"tasks": [
{ "id": "hi_map", "name": "hi_map", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.side", "logic": "upper" } ] } } },
{ "id": "hi_async", "name": "hi_async", "function": {
"name": "logger", "input": {} } } ] }"#,
)
.unwrap(),
]
}
fn task_ids(message: &Message) -> Vec<&str> {
message
.audit_trail()
.iter()
.map(|a| a.task_id.as_ref())
.collect()
}
#[tokio::test]
async fn rollout_splits_traffic_on_the_fully_sync_path() {
let engine = Engine::builder()
.with_workflows(split_pair_sync())
.build()
.unwrap();
let mut low = Message::builder().routing_bucket(7).build();
engine.process_message(&mut low).await.unwrap();
assert_eq!(low.context["data"]["side"], dv(json!("lower")));
assert_eq!(task_ids(&low), vec!["lo"]);
let mut high = Message::builder().routing_bucket(77).build();
engine.process_message(&mut high).await.unwrap();
assert_eq!(high.context["data"]["side"], dv(json!("upper")));
assert_eq!(task_ids(&high), vec!["hi"]);
}
#[tokio::test]
async fn rollout_splits_traffic_on_the_async_path() {
let engine = Engine::builder()
.with_workflows(split_pair_async())
.register("logger", LoggingTask)
.build()
.unwrap();
let mut low = Message::builder().routing_bucket(7).build();
engine.process_message(&mut low).await.unwrap();
assert_eq!(low.context["data"]["side"], dv(json!("lower")));
assert_eq!(task_ids(&low), vec!["lo_map", "lo_async"]);
let mut high = Message::builder().routing_bucket(77).build();
engine.process_message(&mut high).await.unwrap();
assert_eq!(high.context["data"]["side"], dv(json!("upper")));
assert_eq!(task_ids(&high), vec!["hi_map", "hi_async"]);
}
#[tokio::test]
async fn a_workflow_without_a_rollout_runs_for_every_bucket() {
let mut workflows = split_pair_sync();
workflows.push(
Workflow::from_json(
r#"{ "id": "always", "name": "always", "priority": 2, "condition": true,
"tasks": [ { "id": "always_task", "name": "always_task", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.always", "logic": true } ] } } } ] }"#,
)
.unwrap(),
);
let engine = Engine::builder().with_workflows(workflows).build().unwrap();
for bucket in [0u8, 7, 49, 50, 77, 99] {
let mut m = Message::builder().routing_bucket(bucket).build();
engine.process_message(&mut m).await.unwrap();
assert_eq!(
m.context["data"]["always"],
dv(json!(true)),
"the un-split workflow must run for bucket {bucket}"
);
}
}
#[tokio::test]
async fn a_message_with_no_bucket_is_admitted_by_every_split() {
let engine = Engine::builder()
.with_workflows(split_pair_sync())
.build()
.unwrap();
let mut m = Message::from_value(&json!({}));
assert_eq!(m.routing_bucket(), None);
engine.process_message(&mut m).await.unwrap();
assert_eq!(task_ids(&m), vec!["lo", "hi"]);
assert_eq!(m.context["data"]["side"], dv(json!("upper")));
}
#[tokio::test]
async fn rollout_is_honoured_on_the_channel_entry_point_too() {
let workflows = vec![
Workflow::from_json(
r#"{ "id": "lower", "name": "lower", "priority": 0, "channel": "orders",
"condition": true,
"rollout": { "bucket_start": 0, "bucket_end": 50 },
"tasks": [ { "id": "lo", "name": "lo", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.side", "logic": "lower" } ] } } } ] }"#,
)
.unwrap(),
Workflow::from_json(
r#"{ "id": "upper", "name": "upper", "priority": 1, "channel": "orders",
"condition": true,
"rollout": { "bucket_start": 50, "bucket_end": 100 },
"tasks": [ { "id": "hi", "name": "hi", "function": {
"name": "map",
"input": { "mappings": [ { "path": "data.side", "logic": "upper" } ] } } } ] }"#,
)
.unwrap(),
];
let engine = Engine::builder().with_workflows(workflows).build().unwrap();
let mut m = Message::builder().routing_bucket(10).build();
engine
.process_message_for_channel("orders", &mut m)
.await
.unwrap();
assert_eq!(m.context["data"]["side"], dv(json!("lower")));
assert_eq!(task_ids(&m), vec!["lo"]);
}
#[tokio::test]
async fn an_excluded_workflow_emits_one_skipped_step_and_no_side_effects() {
let engine = Engine::builder()
.with_workflows(split_pair_sync())
.build()
.unwrap();
let mut m = Message::builder().routing_bucket(7).build();
let trace = engine.process_message_with_trace(&mut m).await.unwrap();
let skipped: Vec<&ExecutionStep> = trace
.steps
.iter()
.filter(|s| s.result == dataflow_rs::StepResult::Skipped)
.collect();
assert_eq!(trace.skipped_count(), 1);
assert_eq!(skipped[0].workflow_id, "upper");
assert_eq!(
skipped[0].task_id, None,
"workflow-level skip carries no task id"
);
assert_eq!(task_ids(&m), vec!["lo"]);
assert_eq!(
m.context["metadata"]["progress"]["workflow_id"],
dv(json!("lower"))
);
assert_eq!(
m.context["metadata"]["progress"]["task_id"],
dv(json!("lo"))
);
}
#[tokio::test]
async fn a_builder_seeded_message_fires_a_data_condition_with_no_parse_task() {
let wf = Workflow::from_json(
r#"{ "id": "premium", "name": "premium", "priority": 0,
"condition": { ">=": [ { "var": "data.order.total" }, 1000 ] },
"tasks": [ { "id": "discount", "name": "discount", "function": {
"name": "map",
"input": { "mappings": [
{ "path": "data.order.discount",
"logic": { "*": [ { "var": "data.order.total" }, 0.1 ] } } ] } } } ] }"#,
)
.unwrap();
let engine = Engine::builder().with_workflow(wf).build().unwrap();
let mut m = Message::builder()
.data_json(&json!({"order": {"total": 1500}}))
.build();
engine.process_message(&mut m).await.unwrap();
assert_eq!(
m.context["data"]["order"]["discount"],
dv(json!(150.0)),
"a builder-seeded data field must satisfy a data.* condition directly"
);
}
#[tokio::test]
async fn a_seeded_metadata_survives_processing_and_gains_the_engine_stamps() {
let engine = Engine::builder()
.with_workflow(n_map_task_workflow(1))
.build()
.unwrap();
let mut m = Message::builder()
.metadata_json(&json!({"source": "api", "channel": "seeded"}))
.build();
engine.process_message(&mut m).await.unwrap();
assert_eq!(m.context["metadata"]["source"], dv(json!("api")));
assert!(m.context["metadata"].get("processed_at").is_some());
assert!(m.context["metadata"].get("engine_version").is_some());
let mut m2 = Message::builder()
.metadata_json(&json!({"channel": "seeded"}))
.build();
engine
.process_message_for_channel("default", &mut m2)
.await
.unwrap();
assert_eq!(m2.context["metadata"]["channel"], dv(json!("default")));
}