faucet_cli/pipeline_test/
runner.rs1use crate::config::TransformSpec;
11use crate::error::CliResult;
14use crate::transforms::compile_transforms;
15use async_trait::async_trait;
16use chrono::{DateTime, FixedOffset};
17use faucet_core::observability::Labels;
18use faucet_core::{DlqConfig, FaucetError, Pipeline, Sink, Source, StreamPage};
19use serde_json::Value;
20use std::collections::HashMap;
21use std::pin::Pin;
22use std::sync::{Arc, Mutex};
23
24pub struct ResolvedCase {
27 pub name: String,
29 pub transforms: Vec<TransformSpec>,
31 #[cfg(feature = "quality")]
33 pub quality: Option<faucet_core::QualitySpec>,
34 #[cfg(feature = "contract")]
36 pub contract: Option<faucet_core::ContractSpec>,
37 #[cfg(feature = "masking")]
40 pub masking: Option<faucet_core::MaskingSpec>,
41 pub input: Vec<Value>,
43 pub page_size: usize,
45 pub clock: DateTime<FixedOffset>,
47}
48
49#[derive(Debug)]
51pub struct CaseRun {
52 pub written: Vec<Value>,
54 pub dlq_payloads: Vec<Value>,
56 pub records_written: usize,
58 pub error: Option<String>,
61}
62
63pub async fn run_case(case: &ResolvedCase) -> CliResult<CaseRun> {
67 let stages = if case.transforms.is_empty() {
71 Vec::new()
72 } else {
73 let mut transforms = case.transforms.clone();
74 for t in &mut transforms {
75 crate::executor::resolve_now_inplace(&mut t.config, case.clock)?;
76 }
77 compile_transforms(&transforms)?
78 };
79
80 let labels = Labels::new(
81 "faucet-test",
82 case.name.clone(),
83 uuid::Uuid::now_v7().to_string(),
84 );
85 let source: Box<dyn Source> = Box::new(FixtureSource {
86 records: case.input.clone(),
87 page_size: case.page_size,
88 });
89 let source: Box<dyn Source> = if stages.is_empty() {
90 source
91 } else {
92 Box::new(faucet_core::TransformingSource::new(
93 source,
94 stages,
95 labels.clone(),
96 )?)
97 };
98
99 let written = Arc::new(Mutex::new(Vec::new()));
100 let sink = CollectingSink {
101 buffer: Arc::clone(&written),
102 payload_key: None,
103 };
104 let dlq_payloads = Arc::new(Mutex::new(Vec::new()));
107 let dlq_sink = CollectingSink {
108 buffer: Arc::clone(&dlq_payloads),
109 payload_key: Some("payload"),
110 };
111
112 let pipeline = Pipeline::new(source.as_ref(), &sink)
113 .with_name("faucet-test")
114 .with_row(case.name.clone())
115 .with_dlq(DlqConfig::new(Arc::new(dlq_sink)));
118 #[cfg(feature = "quality")]
119 let pipeline = match &case.quality {
120 Some(spec) => {
121 let compiled = faucet_core::CompiledQuality::compile(spec).map_err(|e| {
122 crate::error::CliError::Config(format!("test '{}': quality: {e}", case.name))
123 })?;
124 pipeline.with_quality(Arc::new(compiled))
125 }
126 None => pipeline,
127 };
128 #[cfg(feature = "contract")]
129 let pipeline = match &case.contract {
130 Some(spec) => {
131 let compiled = faucet_core::CompiledContract::compile(spec).map_err(|e| {
132 crate::error::CliError::Config(format!("test '{}': contract: {e}", case.name))
133 })?;
134 pipeline.with_contract(Arc::new(compiled))
135 }
136 None => pipeline,
137 };
138 #[cfg(feature = "masking")]
139 let pipeline = match &case.masking {
140 Some(spec) => {
141 let compiled = faucet_core::CompiledMasking::compile(spec).map_err(|e| {
142 crate::error::CliError::Config(format!("test '{}': masking: {e}", case.name))
143 })?;
144 pipeline.with_masking(Arc::new(compiled))
145 }
146 None => pipeline,
147 };
148
149 let (records_written, error) = match pipeline.run().await {
150 Ok(result) => (result.records_written, None),
151 Err(e) => (0, Some(e.to_string())),
152 };
153
154 let written = std::mem::take(&mut *written.lock().expect("buffer lock"));
156 let dlq_payloads = std::mem::take(&mut *dlq_payloads.lock().expect("dlq lock"));
157 let records_written = if error.is_none() {
158 records_written
159 } else {
160 written.len()
163 };
164 Ok(CaseRun {
165 written,
166 dlq_payloads,
167 records_written,
168 error,
169 })
170}
171
172struct FixtureSource {
176 records: Vec<Value>,
177 page_size: usize,
178}
179
180#[async_trait]
181impl Source for FixtureSource {
182 async fn fetch_with_context(
183 &self,
184 _context: &HashMap<String, Value>,
185 ) -> Result<Vec<Value>, FaucetError> {
186 Ok(self.records.clone())
187 }
188
189 fn stream_pages<'a>(
190 &'a self,
191 _context: &'a HashMap<String, Value>,
192 _batch_size: usize,
193 ) -> Pin<Box<dyn faucet_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
194 let chunk = if self.page_size == 0 {
195 usize::MAX
196 } else {
197 self.page_size
198 };
199 Box::pin(faucet_core::async_stream::try_stream! {
200 let mut iter = self.records.iter().cloned();
201 loop {
202 let page: Vec<Value> = iter.by_ref().take(chunk).collect();
203 if page.is_empty() {
204 break;
205 }
206 yield StreamPage { records: page, bookmark: None };
207 }
208 })
209 }
210
211 fn connector_name(&self) -> &'static str {
212 "fixture"
213 }
214}
215
216struct CollectingSink {
220 buffer: Arc<Mutex<Vec<Value>>>,
221 payload_key: Option<&'static str>,
222}
223
224#[async_trait]
225impl Sink for CollectingSink {
226 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
227 let mut buf = self.buffer.lock().expect("buffer lock");
228 for r in records {
229 match self.payload_key {
230 Some(key) => buf.push(r.get(key).cloned().unwrap_or_else(|| r.clone())),
231 None => buf.push(r.clone()),
232 }
233 }
234 Ok(records.len())
235 }
236
237 fn connector_name(&self) -> &'static str {
238 "capture"
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use serde_json::json;
246
247 fn case(input: Vec<Value>) -> ResolvedCase {
248 ResolvedCase {
249 name: "t".into(),
250 transforms: Vec::new(),
251 #[cfg(feature = "quality")]
252 quality: None,
253 #[cfg(feature = "contract")]
254 contract: None,
255 #[cfg(feature = "masking")]
256 masking: None,
257 input,
258 page_size: 0,
259 clock: chrono::Utc::now().fixed_offset(),
260 }
261 }
262
263 #[tokio::test]
264 async fn passthrough_captures_all_records() {
265 let run = run_case(&case(vec![json!({"a": 1}), json!({"a": 2})]))
266 .await
267 .unwrap();
268 assert_eq!(run.records_written, 2);
269 assert_eq!(run.written, vec![json!({"a": 1}), json!({"a": 2})]);
270 assert!(run.dlq_payloads.is_empty());
271 assert!(run.error.is_none());
272 }
273
274 #[tokio::test]
275 async fn empty_input_yields_empty_run() {
276 let run = run_case(&case(vec![])).await.unwrap();
277 assert_eq!(run.records_written, 0);
278 assert!(run.written.is_empty());
279 }
280
281 #[tokio::test]
282 async fn transforms_apply_in_order() {
283 let mut c = case(vec![json!({"user": {"name": "Ada"}})]);
284 c.transforms = vec![
285 TransformSpec {
286 kind: "flatten".into(),
287 config: json!({"separator": "_"}),
288 },
289 TransformSpec {
290 kind: "set".into(),
291 config: json!({"values": {"src": "fixture"}}),
292 },
293 ];
294 let run = run_case(&c).await.unwrap();
295 assert_eq!(
296 run.written,
297 vec![json!({"user_name": "Ada", "src": "fixture"})]
298 );
299 }
300
301 #[tokio::test]
302 async fn now_tokens_resolve_against_case_clock() {
303 let mut c = case(vec![json!({"a": 1})]);
304 c.clock = chrono::DateTime::parse_from_rfc3339("2026-01-31T00:00:00Z").unwrap();
305 c.transforms = vec![TransformSpec {
306 kind: "set".into(),
307 config: json!({"values": {"day": "${now.date}"}}),
308 }];
309 let run = run_case(&c).await.unwrap();
310 assert_eq!(run.written, vec![json!({"a": 1, "day": "2026-01-31"})]);
311 }
312
313 #[tokio::test]
314 async fn invalid_transform_is_a_setup_error() {
315 let mut c = case(vec![json!({"a": 1})]);
316 c.transforms = vec![TransformSpec {
317 kind: "rename_keys".into(),
318 config: json!({"pattern": "(", "replacement": ""}),
319 }];
320 assert!(run_case(&c).await.is_err());
321 }
322
323 #[tokio::test]
324 async fn page_size_chunks_fixture_pages() {
325 let mut c = case(vec![json!({"a": 1}), json!({"a": 2}), json!({"a": 3})]);
329 c.page_size = 2;
330 #[cfg(feature = "quality")]
331 {
332 c.quality = Some(
333 serde_json::from_value(json!({
334 "batch": [ { "type": "row_count", "min": 2, "on_failure": "abort" } ]
335 }))
336 .unwrap(),
337 );
338 let run = run_case(&c).await.unwrap();
339 assert!(
340 run.error.is_some(),
341 "expected quality abort on the short page"
342 );
343 assert_eq!(run.written.len(), 2);
345 assert_eq!(run.records_written, 2);
346 }
347 }
348
349 #[cfg(feature = "quality")]
350 #[tokio::test]
351 async fn quality_quarantine_routes_to_dlq_capture() {
352 let mut c = case(vec![json!({"id": 1}), json!({"id": null})]);
353 c.quality = Some(
354 serde_json::from_value(json!({
355 "record": [ { "type": "not_null", "field": "id", "on_failure": "quarantine" } ]
356 }))
357 .unwrap(),
358 );
359 let run = run_case(&c).await.unwrap();
360 assert!(run.error.is_none());
361 assert_eq!(run.written, vec![json!({"id": 1})]);
362 assert_eq!(run.dlq_payloads, vec![json!({"id": null})]);
363 }
364
365 #[cfg(feature = "quality")]
366 #[tokio::test]
367 async fn invalid_quality_spec_is_a_setup_error() {
368 let mut c = case(vec![json!({"a": 1})]);
369 c.quality = Some(
370 serde_json::from_value(json!({
371 "record": [ { "type": "regex_match", "field": "a", "pattern": "(", "on_failure": "abort" } ]
372 }))
373 .unwrap(),
374 );
375 assert!(run_case(&c).await.is_err());
376 }
377
378 #[cfg(feature = "contract")]
379 #[tokio::test]
380 async fn contract_fail_aborts_and_quarantine_routes() {
381 let contract = |on_breach: &str| -> faucet_core::ContractSpec {
382 serde_json::from_value(json!({
383 "version": "1.0.0",
384 "on_breach": on_breach,
385 "fields": [ { "name": "id", "type": "integer", "required": true } ]
386 }))
387 .unwrap()
388 };
389 let mut c = case(vec![json!({"id": "not-an-int"})]);
391 c.contract = Some(contract("fail"));
392 let run = run_case(&c).await.unwrap();
393 assert!(
394 run.error
395 .as_deref()
396 .unwrap_or_default()
397 .contains("Contract v1.0.0 violated"),
398 "unexpected error: {:?}",
399 run.error
400 );
401 assert!(run.written.is_empty());
402 assert_eq!(run.records_written, 0);
403
404 let mut c = case(vec![json!({"id": 7}), json!({"id": "bad"})]);
406 c.contract = Some(contract("quarantine"));
407 let run = run_case(&c).await.unwrap();
408 assert!(run.error.is_none());
409 assert_eq!(run.written, vec![json!({"id": 7})]);
410 assert_eq!(run.dlq_payloads, vec![json!({"id": "bad"})]);
411 }
412}