use faucet_cli::config::PipelineConfig;
use faucet_cli::error::CliError;
use faucet_cli::expand::expand;
use faucet_core::StateStore;
use std::path::Path;
fn csv_to_jsonl_yaml(input: &Path, output: &Path, state_dir: &Path, sla: &str) -> String {
format!(
r#"version: 1
name: slatest
pipeline:
source: {{ type: csv, config: {{ path: {input} }} }}
sink: {{ type: jsonl, config: {{ path: {output} }} }}
state: {{ type: file, config: {{ path: {state} }} }}
{sla}
"#,
input = input.display(),
output = output.display(),
state = state_dir.display(),
)
}
fn parse(yaml: &str) -> PipelineConfig {
PipelineConfig::from_text(yaml, Path::new("sla.yaml")).expect("config parses")
}
#[test]
fn expand_rejects_stateful_sla_without_state_block() {
let cfg = parse(
r#"version: 1
pipeline:
source: { type: csv, config: { path: in.csv } }
sink: { type: jsonl, config: { path: out.jsonl } }
sla:
max_staleness_secs: 3600
"#,
);
let err = expand(&cfg).unwrap_err();
assert!(
matches!(&err, CliError::Config(m) if m.contains("state") && m.contains("sla")),
"expected the sla-needs-state gate, got: {err}"
);
}
#[test]
fn expand_rejects_invalid_sla_spec() {
let cfg = parse(
r#"version: 1
pipeline:
source: { type: csv, config: { path: in.csv } }
sink: { type: jsonl, config: { path: out.jsonl } }
sla: {}
"#,
);
let err = expand(&cfg).unwrap_err();
assert!(
matches!(&err, CliError::Config(m) if m.contains("declares no checks")),
"expected the empty-spec rejection, got: {err}"
);
let cfg = parse(
r#"version: 1
pipeline:
source: { type: csv, config: { path: in.csv } }
sink: { type: jsonl, config: { path: out.jsonl } }
state: { type: memory, config: {} }
sla:
volume_anomaly: { method: zscore, sensitivity: 0 }
"#,
);
let err = expand(&cfg).unwrap_err();
assert!(
matches!(&err, CliError::Config(m) if m.contains("sensitivity")),
"expected the sensitivity rejection, got: {err}"
);
}
#[test]
fn expand_allows_stateless_min_rows_without_state() {
let cfg = parse(
r#"version: 1
pipeline:
source: { type: csv, config: { path: in.csv } }
sink: { type: jsonl, config: { path: out.jsonl } }
sla:
min_rows_per_run: 1
"#,
);
let nodes = expand(&cfg).expect("stateless min_rows needs no state block");
assert_eq!(nodes.len(), 1);
}
#[tokio::test]
async fn successful_run_persists_sla_baseline() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("in.csv");
let output = dir.path().join("out.jsonl");
let state_dir = dir.path().join("state");
std::fs::write(&input, "id\n1\n2\n3\n").unwrap();
let yaml = csv_to_jsonl_yaml(
&input,
&output,
&state_dir,
"sla:\n max_staleness_secs: 3600\n min_rows_per_run: 1\n",
);
let summary = faucet_cli::run_from_yaml_str(&yaml).await.expect("run ok");
assert!(!summary.had_failures());
assert_eq!(summary.invocations[0].records_written, 3);
let store = faucet_core::FileStateStore::new(&state_dir);
let v = store
.get("slatest::row-0::__sla__")
.await
.unwrap()
.expect("SLA state persisted");
assert_eq!(v["volumes"], serde_json::json!([3]));
assert!(v["last_success_unix"].as_i64().unwrap() > 0);
let summary = faucet_cli::run_from_yaml_str(&yaml).await.expect("run ok");
assert!(!summary.had_failures());
let v = store.get("slatest::row-0::__sla__").await.unwrap().unwrap();
assert_eq!(v["volumes"], serde_json::json!([3, 3]));
}
#[tokio::test]
async fn sla_violation_never_fails_the_run() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("in.csv");
let output = dir.path().join("out.jsonl");
let state_dir = dir.path().join("state");
std::fs::write(&input, "id\n1\n").unwrap();
let yaml = csv_to_jsonl_yaml(
&input,
&output,
&state_dir,
"sla:\n min_rows_per_run: 100\n",
);
let summary = faucet_cli::run_from_yaml_str(&yaml).await.expect("run ok");
assert!(!summary.had_failures(), "SLA violations must not fail runs");
assert_eq!(summary.invocations[0].records_written, 1);
}
#[tokio::test]
async fn failed_run_evaluates_staleness_without_breaking_error_reporting() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.csv");
let output = dir.path().join("out.jsonl");
let state_dir = dir.path().join("state");
let store = faucet_core::FileStateStore::new(&state_dir);
store
.put(
"slatest::row-0::__sla__",
&serde_json::json!({"last_success_unix": 1, "volumes": [5, 5, 5]}),
)
.await
.unwrap();
let yaml = csv_to_jsonl_yaml(
&missing,
&output,
&state_dir,
"sla:\n max_staleness_secs: 60\n",
);
let summary = faucet_cli::run_from_yaml_str(&yaml).await.expect("summary");
assert!(summary.had_failures(), "the source error is still reported");
let v = store.get("slatest::row-0::__sla__").await.unwrap().unwrap();
assert_eq!(v["last_success_unix"], serde_json::json!(1));
assert_eq!(v["volumes"], serde_json::json!([5, 5, 5]));
}
#[tokio::test]
async fn dry_run_skips_sla_evaluation() {
use faucet_cli::executor::{ExecuteOptions, run_expanded};
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("in.csv");
let output = dir.path().join("out.jsonl");
let state_dir = dir.path().join("state");
std::fs::write(&input, "id\n1\n").unwrap();
let yaml = csv_to_jsonl_yaml(&input, &output, &state_dir, "sla:\n min_rows_per_run: 1\n");
let cfg = parse(&yaml);
let nodes = expand(&cfg).unwrap();
let summary = run_expanded(
nodes,
ExecuteOptions {
pipeline_name: "slatest".into(),
execution: None,
dry_run: true,
limit: None,
state_path_override: None,
shard: None,
auth: Default::default(),
clock: chrono::Utc::now().fixed_offset(),
cancel: None,
resilience: None,
sla: cfg.sla.clone(),
#[cfg(feature = "lineage")]
lineage: None,
#[cfg(feature = "lineage")]
lineage_cfg: None,
#[cfg(feature = "notify")]
notifier: None,
#[cfg(feature = "catalog")]
catalog: None,
},
)
.await
.unwrap();
assert!(!summary.had_failures());
let store = faucet_core::FileStateStore::new(&state_dir);
assert!(
store
.get("slatest::row-0::__sla__")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn doctor_reports_sla_probes_cold_and_warm() {
use faucet_cli::commands::doctor::probe_roots;
use faucet_core::check::{CheckContext, ProbeStatus};
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("in.csv");
let output = dir.path().join("out.jsonl");
let state_dir = dir.path().join("state");
std::fs::write(&input, "id\n1\n2\n").unwrap();
let yaml = csv_to_jsonl_yaml(
&input,
&output,
&state_dir,
"sla:\n max_staleness_secs: 86400\n volume_anomaly: { min_history: 3 }\n",
);
let cfg = parse(&yaml);
let nodes = expand(&cfg).unwrap();
let auth = Default::default();
let ctx = CheckContext::default();
let invs = probe_roots(&nodes, &auth, &ctx, cfg.sla.as_ref(), "slatest").await;
let sla_probes: Vec<_> = invs[0].probes.iter().filter(|p| p.role == "sla").collect();
assert_eq!(sla_probes.len(), 2, "{:?}", invs[0].probes);
assert_eq!(sla_probes[0].name, "staleness");
assert!(matches!(sla_probes[0].status, ProbeStatus::Skip { .. }));
assert_eq!(sla_probes[1].name, "baseline");
assert!(matches!(sla_probes[1].status, ProbeStatus::Skip { .. }));
faucet_cli::run_from_yaml_str(&yaml).await.expect("run ok");
let invs = probe_roots(&nodes, &auth, &ctx, cfg.sla.as_ref(), "slatest").await;
let sla_probes: Vec<_> = invs[0].probes.iter().filter(|p| p.role == "sla").collect();
assert!(
matches!(sla_probes[0].status, ProbeStatus::Pass),
"{sla_probes:?}"
);
assert!(matches!(sla_probes[1].status, ProbeStatus::Skip { .. }));
let store = faucet_core::FileStateStore::new(&state_dir);
store
.put(
"slatest::row-0::__sla__",
&serde_json::json!({"last_success_unix": 1, "volumes": [2, 2, 2]}),
)
.await
.unwrap();
let invs = probe_roots(&nodes, &auth, &ctx, cfg.sla.as_ref(), "slatest").await;
let sla_probes: Vec<_> = invs[0].probes.iter().filter(|p| p.role == "sla").collect();
assert!(
matches!(sla_probes[0].status, ProbeStatus::Fail { .. }),
"{sla_probes:?}"
);
assert!(matches!(sla_probes[1].status, ProbeStatus::Pass));
let invs = probe_roots(&nodes, &auth, &ctx, None, "slatest").await;
assert!(invs[0].probes.iter().all(|p| p.role != "sla"));
}