Skip to main content

faucet_cli/commands/
test.rs

1//! `faucet test` — run fixture-based offline pipeline tests (#210).
2//!
3//! Loads one or more spec files, resolves each case's pipeline logic (from a
4//! referenced config file or the inline `pipeline:` block), streams the
5//! fixture records through the real pipeline pass chain with in-memory
6//! source/sink/DLQ, and reports pass/fail per case. Exits non-zero (the
7//! failed-case count) when any case fails, so CI can gate on it.
8
9use crate::cli::TestArgs;
10use crate::config::PipelineConfig;
11use crate::error::{CliError, CliResult};
12use crate::expand::expand;
13use crate::pipeline_test::report::{CaseOutcome, TestReport};
14use crate::pipeline_test::runner::{ResolvedCase, run_case};
15use crate::pipeline_test::spec::{TestCase, load_spec};
16use crate::pipeline_test::{diff, fixtures};
17use chrono::{DateTime, FixedOffset};
18use std::path::Path;
19
20/// Execute the `test` subcommand.
21pub async fn run(args: TestArgs) -> CliResult<()> {
22    let cwd = std::env::current_dir()?;
23    let env_path =
24        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
25    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
26
27    let default_clock = crate::commands::run::resolve_run_clock(args.clock.as_deref())?;
28
29    let mut outcomes: Vec<CaseOutcome> = Vec::new();
30    for spec_path in &args.specs {
31        let spec = load_spec(spec_path)?;
32        let spec_dir = spec_path.parent().unwrap_or(Path::new("."));
33        for case in &spec.tests {
34            if let Some(f) = &args.filter
35                && !case.name.contains(f.as_str())
36            {
37                continue;
38            }
39            let resolved = resolve_case(case, spec_path, spec_dir, default_clock, &args).await?;
40            let run = run_case(&resolved).await?;
41            let failures = diff::evaluate(&case.expect, &run);
42            outcomes.push(CaseOutcome::new(
43                case.name.clone(),
44                spec_path.display().to_string(),
45                failures,
46            ));
47        }
48    }
49
50    if outcomes.is_empty() {
51        return Err(CliError::Config(match &args.filter {
52            Some(f) => format!("no test cases match --filter '{f}'"),
53            None => "no test cases found in the given spec file(s)".to_string(),
54        }));
55    }
56
57    let report = TestReport::new(outcomes);
58    if args.json {
59        println!("{}", report.render_json());
60    } else {
61        print!("{}", report.render_human());
62    }
63    if report.failed > 0 {
64        return Err(CliError::TestsFailed {
65            failed: report.failed,
66        });
67    }
68    Ok(())
69}
70
71/// Resolve a case's pipeline logic + fixtures into the runner's input.
72async fn resolve_case(
73    case: &TestCase,
74    spec_path: &Path,
75    spec_dir: &Path,
76    default_clock: DateTime<FixedOffset>,
77    args: &TestArgs,
78) -> CliResult<ResolvedCase> {
79    let at = |msg: String| {
80        CliError::Config(format!(
81            "{}: test '{}': {msg}",
82            spec_path.display(),
83            case.name
84        ))
85    };
86    let clock = match &case.clock {
87        Some(s) => crate::commands::run::resolve_run_clock(Some(s))
88            .map_err(|e| at(format!("clock: {e}")))?,
89        None => default_clock,
90    };
91    let input = fixtures::load_input(spec_dir, &case.input)?;
92
93    let resolved = match (&case.config, &case.pipeline) {
94        (Some(config_rel), None) => {
95            let config_path = spec_dir.join(config_rel);
96            // Offline by default: leave `${vault:…}`-style directives
97            // unresolved — the source/sink configs that hold them are
98            // replaced by fixtures anyway. `--resolve-secrets` opts into the
99            // real (network) resolution for the rare secret inside a
100            // transform/quality/contract block.
101            let cfg = if args.resolve_secrets {
102                PipelineConfig::from_path_async(&config_path, args.profile.as_deref()).await?
103            } else {
104                PipelineConfig::from_path_tolerating_secrets(&config_path, args.profile.as_deref())?
105            };
106            let nodes = expand(&cfg)?;
107            let node = match &case.row {
108                Some(row) => nodes.iter().find(|n| &n.id == row).ok_or_else(|| {
109                    at(format!(
110                        "row '{row}' not found in '{}' — available rows: {}",
111                        config_path.display(),
112                        ids(&nodes)
113                    ))
114                })?,
115                None if nodes.len() == 1 => &nodes[0],
116                None => {
117                    return Err(at(format!(
118                        "'{}' expands to {} invocations — set `row` to one of: {}",
119                        config_path.display(),
120                        nodes.len(),
121                        ids(&nodes)
122                    )));
123                }
124            };
125            if node.schema.is_some() {
126                tracing::warn!(
127                    test = %case.name,
128                    "the config's `schema:` (drift) block is inert in `faucet test` — \
129                     there is no destination schema offline"
130                );
131            }
132            ResolvedCase {
133                name: case.name.clone(),
134                transforms: node.transforms.clone(),
135                #[cfg(feature = "quality")]
136                quality: node.quality.clone(),
137                #[cfg(feature = "contract")]
138                contract: node.contract.clone(),
139                #[cfg(feature = "masking")]
140                masking: node.masking.clone(),
141                input,
142                page_size: case.page_size,
143                clock,
144            }
145        }
146        (None, Some(inline)) => ResolvedCase {
147            name: case.name.clone(),
148            transforms: inline.transforms.clone(),
149            #[cfg(feature = "quality")]
150            quality: inline.quality.clone(),
151            #[cfg(feature = "contract")]
152            contract: inline.contract.clone(),
153            #[cfg(feature = "masking")]
154            masking: inline.masking.clone(),
155            input,
156            page_size: case.page_size,
157            clock,
158        },
159        // Spec validation guarantees exactly one of config/pipeline is set.
160        _ => unreachable!("spec validation enforces config XOR pipeline"),
161    };
162    Ok(resolved)
163}
164
165fn ids(nodes: &[crate::expand::ExpandedNode]) -> String {
166    nodes
167        .iter()
168        .map(|n| n.id.as_str())
169        .collect::<Vec<_>>()
170        .join(", ")
171}