use faucet_cli::config::PipelineConfig;
use faucet_cli::executor::{ExecuteOptions, run_expanded};
use faucet_cli::expand::expand;
use sqlx::Row;
async fn seed(db: &str) {
let pool = sqlx::SqlitePool::connect(db).await.unwrap();
sqlx::query("CREATE TABLE assoc (id INTEGER PRIMARY KEY, contact_id INTEGER, label TEXT)")
.execute(&pool)
.await
.unwrap();
for (id, contact, label) in [
(1, 7, "stale-a"),
(2, 7, "stale-b"),
(3, 7, "keep-me"),
(10, 8, "other-contact"),
(11, 8, "other-contact-2"),
] {
sqlx::query("INSERT INTO assoc (id, contact_id, label) VALUES (?, ?, ?)")
.bind(id)
.bind(contact)
.bind(label)
.execute(&pool)
.await
.unwrap();
}
pool.close().await;
}
async fn rows(db: &str) -> Vec<(i64, i64)> {
let pool = sqlx::SqlitePool::connect(db).await.unwrap();
let out = sqlx::query("SELECT id, contact_id FROM assoc ORDER BY id")
.fetch_all(&pool)
.await
.unwrap()
.iter()
.map(|r| (r.get::<i64, _>("id"), r.get::<i64, _>("contact_id")))
.collect();
pool.close().await;
out
}
fn config_yaml(csv: &std::path::Path, db: &str, cleanup: bool) -> String {
format!(
r#"
version: 1
name: assoc_mirror
pipeline:
source:
type: csv
config:
path: "{csv}"
complete_for:
scope:
contact_id: 7
on_missing: {on_missing}
sink:
type: sqlite
config:
database_url: "{db}"
table_name: assoc
column_mapping: auto_map
write_mode: upsert
key: [id]
"#,
csv = csv.display(),
db = db,
on_missing = if cleanup { "delete" } else { "ignore" }
)
}
fn opts(name: &str) -> ExecuteOptions {
ExecuteOptions {
pipeline_name: name.into(),
run_id: None,
execution: None,
dry_run: false,
limit: None,
state_path_override: None,
shard: None,
auth: Default::default(),
clock: chrono::Utc::now().fixed_offset(),
cancel: None,
resilience: None,
sla: None,
reconcile: None,
#[cfg(feature = "lineage")]
lineage: None,
#[cfg(feature = "lineage")]
lineage_cfg: None,
#[cfg(feature = "notify")]
notifier: None,
#[cfg(feature = "catalog")]
catalog: None,
}
}
async fn run(cfg_yaml: &str, dir: &std::path::Path, o: ExecuteOptions) -> usize {
let path = dir.join("p.yaml");
std::fs::write(&path, cfg_yaml).unwrap();
let cfg = PipelineConfig::from_text(cfg_yaml, &path).expect("config parses");
let nodes = expand(&cfg).expect("expand");
let summary = run_expanded(nodes, o).await.expect("run");
let errs: Vec<String> = summary
.invocations
.iter()
.filter_map(|i| i.error.clone())
.collect();
assert!(!summary.had_failures(), "pipeline should succeed: {errs:?}");
summary.invocations.len()
}
#[tokio::test]
async fn deletes_only_stale_rows_inside_the_claimed_scope() {
let dir = tempfile::tempdir().unwrap();
let db = format!(
"sqlite://{}?mode=rwc",
dir.path().join("d.sqlite").display()
);
seed(&db).await;
let csv = dir.path().join("in.csv");
std::fs::write(&csv, "id,contact_id,label\n3,7,keep-me\n").unwrap();
run(
&config_yaml(&csv, &db, true),
dir.path(),
opts("cleanup_on"),
)
.await;
assert_eq!(
rows(&db).await,
vec![(3, 7), (10, 8), (11, 8)],
"stale rows 1 and 2 removed; the written row and contact 8's rows untouched"
);
}
#[tokio::test]
async fn an_empty_fetch_clears_the_whole_scope() {
let dir = tempfile::tempdir().unwrap();
let db = format!(
"sqlite://{}?mode=rwc",
dir.path().join("d.sqlite").display()
);
seed(&db).await;
let csv = dir.path().join("in.csv");
std::fs::write(&csv, "id,contact_id,label\n").unwrap();
run(
&config_yaml(&csv, &db, true),
dir.path(),
opts("cleanup_empty"),
)
.await;
assert_eq!(
rows(&db).await,
vec![(10, 8), (11, 8)],
"contact 7's scope emptied; contact 8 untouched"
);
}
#[tokio::test]
async fn without_the_sink_opt_in_stale_rows_survive() {
let dir = tempfile::tempdir().unwrap();
let db = format!(
"sqlite://{}?mode=rwc",
dir.path().join("d.sqlite").display()
);
seed(&db).await;
let csv = dir.path().join("in.csv");
std::fs::write(&csv, "id,contact_id,label\n3,7,keep-me\n").unwrap();
run(
&config_yaml(&csv, &db, false),
dir.path(),
opts("cleanup_off"),
)
.await;
assert_eq!(
rows(&db).await,
vec![(1, 7), (2, 7), (3, 7), (10, 8), (11, 8)],
"no cleanup opt-in → stale rows remain"
);
}
#[tokio::test]
async fn limit_run_does_not_delete() {
let dir = tempfile::tempdir().unwrap();
let db = format!(
"sqlite://{}?mode=rwc",
dir.path().join("d.sqlite").display()
);
seed(&db).await;
let csv = dir.path().join("in.csv");
std::fs::write(&csv, "id,contact_id,label\n3,7,keep-me\n").unwrap();
let mut o = opts("cleanup_limit");
o.limit = Some(0);
run(&config_yaml(&csv, &db, true), dir.path(), o).await;
assert_eq!(
rows(&db).await.len(),
5,
"a --limit run must never delete: its written set is synthetic"
);
}
#[tokio::test]
async fn dry_run_does_not_delete() {
let dir = tempfile::tempdir().unwrap();
let db = format!(
"sqlite://{}?mode=rwc",
dir.path().join("d.sqlite").display()
);
seed(&db).await;
let csv = dir.path().join("in.csv");
std::fs::write(&csv, "id,contact_id,label\n3,7,keep-me\n").unwrap();
let mut o = opts("cleanup_dry");
o.dry_run = true;
run(&config_yaml(&csv, &db, true), dir.path(), o).await;
assert_eq!(
rows(&db).await.len(),
5,
"--dry-run must never delete: the sink is a counter, not the real sink"
);
}
fn expand_err(yaml: &str) -> String {
let path = std::path::PathBuf::from("p.yaml");
let cfg = PipelineConfig::from_text(yaml, &path).expect("config parses");
expand(&cfg).expect_err("expand must reject").to_string()
}
#[test]
fn an_empty_scope_is_rejected() {
let err = expand_err(
r#"
version: 1
pipeline:
source:
type: csv
config: { path: "./x.csv" }
complete_for:
scope: {}
on_missing: delete
sink:
type: sqlite
config:
database_url: "sqlite://x.db"
table_name: t
column_mapping: auto_map
write_mode: upsert
key: [id]
"#,
);
assert!(err.contains("empty"), "{err}");
}
#[test]
fn a_claim_without_on_missing_delete_is_inert() {
let path = std::path::PathBuf::from("p.yaml");
let cfg = PipelineConfig::from_text(
r#"
version: 1
pipeline:
source:
type: csv
config: { path: "./x.csv" }
complete_for:
scope: { contact_id: 7 }
sink:
type: jsonl
config: { path: "./out.jsonl" }
"#,
&path,
)
.expect("config parses");
let nodes = expand(&cfg).expect("an inert claim must not be gated at all");
assert!(
nodes[0].cleanup_scope.is_none(),
"on_missing defaults to ignore, so no cleanup is scheduled"
);
}
#[test]
fn cleanup_on_an_append_sink_is_rejected() {
let err = expand_err(
r#"
version: 1
pipeline:
source:
type: csv
config: { path: "./x.csv" }
complete_for:
scope: { contact_id: 7 }
on_missing: delete
sink:
type: jsonl
config:
path: "./out.jsonl"
"#,
);
assert!(
err.contains("jsonl"),
"error should name the unsupported sink: {err}"
);
}
#[test]
fn complete_for_on_a_sink_is_rejected() {
let err = expand_err(
r#"
version: 1
pipeline:
source: { type: csv, config: { path: "./x.csv" } }
sink:
type: sqlite
config:
database_url: "sqlite://x.db"
table_name: t
column_mapping: auto_map
complete_for:
scope: { contact_id: 7 }
"#,
);
assert!(err.contains("complete_for"), "{err}");
}
#[test]
fn quarantining_quality_policy_is_rejected() {
let err = expand_err(
r#"
version: 1
pipeline:
source:
type: csv
config: { path: "./x.csv" }
complete_for:
scope: { contact_id: 7 }
on_missing: delete
sink:
type: sqlite
config:
database_url: "sqlite://x.db"
table_name: t
column_mapping: auto_map
write_mode: upsert
key: [id]
quality:
record:
- type: not_null
field: id
on_failure: quarantine
dlq:
sink: { type: jsonl, config: { path: "./dlq.jsonl" } }
"#,
);
assert!(
err.contains("quarantin"),
"error should explain the quarantine incompatibility: {err}"
);
}