Skip to main content

faucet_conformance/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! # faucet-conformance
4//!
5//! A reusable test battery that any faucet connector can call from its own
6//! `tests/` to prove it upholds the connector contract. Passing this battery is
7//! the **Tier-1** criterion for a connector — there is no separate tiering
8//! scheme; a connector is "supported" exactly when it invokes and passes these
9//! checks in CI.
10//!
11//! ```no_run
12//! # async fn ex() {
13//! use faucet_conformance as conf;
14//! let source = /* your Source */
15//! #     conf::doubles::CountingSource::new(1000, 100);
16//! conf::assert_config_schema_valid(&source);
17//! conf::assert_bounded_memory(&source, 100, 1000).await;
18//! # }
19//! ```
20//!
21//! The core contract checks:
22//! 1. [`assert_config_schema_valid`] — the config schema is a valid JSON Schema.
23//! 2. [`assert_bounded_memory`] — the source pages instead of buffering.
24//! 3. [`assert_bookmark_roundtrip`] — an incremental source resumes from its
25//!    bookmark rather than restarting.
26//! 4. [`assert_idempotent_replay`] — re-delivering committed rows leaves no
27//!    duplicates (atomic-watermark or keyed-upsert mechanism).
28//! 5. [`assert_capabilities_truthful`] — advertised capabilities match real
29//!    behaviour.
30//! 6. [`assert_errors_not_panics`] — failures surface as a typed
31//!    [`faucet_core::FaucetError`], never a panic.
32//!
33//! Capability-demonstration checks — each proves a connector that *advertises* a
34//! capability actually *demonstrates* it:
35//! 7. [`assert_write_modes_truthful`] — a sink advertising `Upsert`/`Delete`
36//!    genuinely converges by key and removes on delete, and missing/null keys
37//!    are reported as failed rather than silently written.
38//! 8. [`assert_schema_evolution_effective`] — an evolvable sink's `evolve_schema`
39//!    makes the added column appear in a fresh `current_schema()`.
40//! 9. [`assert_batch_size_zero_single_page`] — a source built with `batch_size =
41//!    0` yields the whole result set as one page.
42//! 10. [`assert_connector_name_nonempty`] — `connector_name()` is non-empty (an
43//!     empty name becomes the `"unknown"` metric label).
44//! 11. [`assert_preflight_check_wellformed`] — `check()` returns `Ok(CheckReport)`
45//!     with well-formed probes; a probe failure is a `Fail` probe, not an `Err`.
46//!
47//! Each check has both a passing and a `#[should_panic]` failing test in this
48//! crate — a check that cannot fail is worthless.
49
50pub mod doubles;
51
52use std::collections::HashMap;
53
54use faucet_core::{Sink, Source, Value};
55use futures::StreamExt;
56
57// ── Check 1: config schema validity ─────────────────────────────────────────
58
59/// Anything that can expose a config JSON Schema + a label — blanket-implemented
60/// for every [`Source`] so [`assert_config_schema_valid`] accepts a source
61/// directly. (Sinks can be checked via [`assert_config_schema_valid_value`].)
62pub trait HasConfigSchema {
63    /// The connector's advertised config schema.
64    fn conformance_schema(&self) -> Value;
65    /// A human label for assertion messages.
66    fn conformance_label(&self) -> String;
67}
68
69impl<T: Source + ?Sized> HasConfigSchema for T {
70    fn conformance_schema(&self) -> Value {
71        self.config_schema()
72    }
73    fn conformance_label(&self) -> String {
74        self.connector_name().to_string()
75    }
76}
77
78/// **Check 1.** Assert the connector's `config_schema()` is a structurally valid
79/// JSON Schema that round-trips through `serde_json`.
80///
81/// Panics (fails the test) on: a non-object schema, a schema with no recognized
82/// schema shape, a non-object `properties`, or a serialize→parse→serialize that
83/// is not stable.
84pub fn assert_config_schema_valid<C: HasConfigSchema + ?Sized>(connector: &C) {
85    assert_config_schema_valid_value(
86        &connector.conformance_schema(),
87        &connector.conformance_label(),
88    );
89}
90
91/// The value-level core of [`assert_config_schema_valid`] — usable for sinks:
92/// `assert_config_schema_valid_value(&sink.config_schema(), sink.connector_name())`.
93pub fn assert_config_schema_valid_value(schema: &Value, label: &str) {
94    let obj = schema.as_object().unwrap_or_else(|| {
95        panic!("[{label}] config_schema() must be a JSON object, got: {schema}")
96    });
97
98    // Recognized as *some* JSON Schema shape.
99    let recognized = [
100        "type",
101        "properties",
102        "$ref",
103        "oneOf",
104        "allOf",
105        "anyOf",
106        "$schema",
107        "enum",
108    ]
109    .iter()
110    .any(|k| obj.contains_key(*k));
111    assert!(
112        recognized,
113        "[{label}] config_schema() has no recognizable JSON Schema keyword: {schema}"
114    );
115
116    if let Some(props) = obj.get("properties") {
117        assert!(
118            props.is_object(),
119            "[{label}] config_schema().properties must be an object, got: {props}"
120        );
121    }
122    if let Some(ty) = obj.get("type") {
123        assert!(
124            ty.is_string() || ty.is_array(),
125            "[{label}] config_schema().type must be a string or array, got: {ty}"
126        );
127    }
128
129    // Round-trip: serialize → parse → serialize must be stable.
130    let text = serde_json::to_string(schema).expect("schema serializes");
131    let reparsed: Value = serde_json::from_str(&text).expect("schema re-parses");
132    assert_eq!(
133        &reparsed, schema,
134        "[{label}] config_schema() does not round-trip through serde_json"
135    );
136}
137
138// ── Check 2: bounded memory ──────────────────────────────────────────────────
139
140/// **Check 2.** Drive `stream_pages` over a source that yields `total` records
141/// and assert the consumer never holds more than ~`batch_size` records live at
142/// once (i.e. the source pages instead of buffering everything).
143///
144/// Requires `batch_size > 0` and `total > batch_size` for a meaningful result.
145/// Asserts: every record is streamed (`sum == total`), the largest single page
146/// is `<= batch_size`, and strictly `< total` (proving the source did not emit
147/// the whole set as one page).
148pub async fn assert_bounded_memory<S: Source + ?Sized>(
149    source: &S,
150    batch_size: usize,
151    total: usize,
152) {
153    assert!(
154        batch_size > 0,
155        "batch_size must be > 0 for a bounded-memory check"
156    );
157    assert!(
158        total > batch_size,
159        "total ({total}) must exceed batch_size ({batch_size}) for a meaningful check"
160    );
161    let label = source.connector_name();
162
163    let ctx: HashMap<String, Value> = HashMap::new();
164    let mut stream = source.stream_pages(&ctx, batch_size);
165    let mut seen = 0usize;
166    let mut peak = 0usize;
167    while let Some(page) = stream.next().await {
168        let page = page.unwrap_or_else(|e| panic!("[{label}] stream_pages errored: {e}"));
169        peak = peak.max(page.records.len());
170        seen += page.records.len();
171        // `page` is dropped here — the consumer only ever holds one page.
172    }
173
174    assert_eq!(
175        seen, total,
176        "[{label}] streamed {seen} records, expected {total}"
177    );
178    assert!(
179        peak <= batch_size,
180        "[{label}] peak page {peak} exceeds batch_size {batch_size} (not bounded)"
181    );
182    assert!(
183        peak < total,
184        "[{label}] peak page {peak} == total: source buffered the whole set into one page"
185    );
186}
187
188// ── Check 3: bookmark round-trip (resumable sources) ─────────────────────────
189
190/// **Check 3.** Drive an incremental source to completion, capture the bookmark
191/// it emits, feed it back via
192/// [`apply_start_bookmark`](Source::apply_start_bookmark), and assert the second
193/// run resumes *after* that point — strictly fewer records reappear (zero for a
194/// fully-consumed static source).
195///
196/// Only meaningful for a source that actually emits a bookmark and honours it.
197/// Panics if the source produces no bookmark (nothing to round-trip), or if the
198/// resumed run replays the same volume (the bookmark was ignored).
199pub async fn assert_bookmark_roundtrip<S: Source + ?Sized>(source: &S) {
200    let label = source.connector_name();
201    let ctx: HashMap<String, Value> = HashMap::new();
202
203    // First run: consume every page, remembering how many records we saw and the
204    // last non-null bookmark.
205    let (first_records, bookmark) = drain(source, &ctx, label).await;
206    assert!(
207        first_records > 0,
208        "[{label}] produced no records — cannot exercise bookmark round-trip"
209    );
210    let bookmark = bookmark.unwrap_or_else(|| {
211        panic!("[{label}] produced no bookmark to round-trip (stream_pages never set one)")
212    });
213
214    // Resume from the captured bookmark.
215    source
216        .apply_start_bookmark(bookmark.clone())
217        .await
218        .unwrap_or_else(|e| panic!("[{label}] apply_start_bookmark errored: {e}"));
219
220    let (second_records, _) = drain(source, &ctx, label).await;
221    assert!(
222        second_records < first_records,
223        "[{label}] resumed run replayed {second_records} records (first run: {first_records}); \
224         the bookmark {bookmark} was ignored — no incremental resume"
225    );
226}
227
228/// Drive `stream_pages` to completion, returning `(record_count, last_bookmark)`.
229async fn drain<S: Source + ?Sized>(
230    source: &S,
231    ctx: &HashMap<String, Value>,
232    label: &str,
233) -> (usize, Option<Value>) {
234    let mut stream = source.stream_pages(ctx, 100);
235    let mut count = 0usize;
236    let mut last_bookmark = None;
237    while let Some(page) = stream.next().await {
238        let page = page.unwrap_or_else(|e| panic!("[{label}] stream_pages errored: {e}"));
239        count += page.records.len();
240        if page.bookmark.is_some() {
241            last_bookmark = page.bookmark;
242        }
243    }
244    (count, last_bookmark)
245}
246
247// ── Check 4: idempotent replay (no duplicates on re-delivery) ─────────────────
248
249/// **Check 4.** Assert re-delivering already-committed rows leaves no
250/// duplicates in the destination — the trust-critical effectively-once check.
251///
252/// `distinct_count` returns the number of distinct rows the destination
253/// currently holds (for a double, `|| async { sink.len() }`; for a real sink, a
254/// `SELECT count(*)`). Records are keyed on the field `"id"`, so a real sink
255/// under test must be configured `write_mode: upsert` with `key: ["id"]`.
256///
257/// Dispatches on the mechanism the sink advertises:
258/// - `supports_idempotent_writes()` → the **atomic-watermark** path: writing a
259///   page durably records a commit token; a crash-replay (guarded by
260///   `last_committed_token`, exactly as the pipeline guards it) does not
261///   re-write, and forward progress still advances.
262/// - else `dedups_by_key()` → the **keyed-upsert** path: overlapping keys across
263///   pages converge to one row each.
264/// - neither → panics (the sink advertises no idempotency mechanism to test).
265pub async fn assert_idempotent_replay<S, F, Fut>(sink: &S, distinct_count: F)
266where
267    S: Sink + ?Sized,
268    F: Fn() -> Fut,
269    Fut: std::future::Future<Output = usize>,
270{
271    let label = sink.connector_name();
272    if sink.supports_idempotent_writes() {
273        assert_watermark_idempotent(sink, &distinct_count, label).await;
274    } else if sink.dedups_by_key() {
275        assert_keyed_convergence(sink, &distinct_count, label).await;
276    } else {
277        panic!(
278            "[{label}] advertises no idempotency mechanism \
279             (supports_idempotent_writes=false, dedups_by_key=false) — nothing to verify"
280        );
281    }
282}
283
284/// Build test records keyed on `"id"` with a non-key `"v"` column, so a SQL
285/// upsert (`ON CONFLICT(id) DO UPDATE SET v = …`) has something to set — a
286/// single key-only column would produce an empty SET clause.
287fn rows(ids: &[i64]) -> Vec<Value> {
288    ids.iter()
289        .map(|i| serde_json::json!({ "id": i, "v": format!("v{i}") }))
290        .collect()
291}
292
293async fn assert_watermark_idempotent<S, F, Fut>(sink: &S, count: &F, label: &str)
294where
295    S: Sink + ?Sized,
296    F: Fn() -> Fut,
297    Fut: std::future::Future<Output = usize>,
298{
299    let scope = "conformance::idem";
300    let before = count().await;
301
302    // Page 1 with the first commit token.
303    let t1 = faucet_core::format_token(1);
304    let p1 = rows(&[1, 2, 3]);
305    sink.write_batch_idempotent(&p1, scope, &t1)
306        .await
307        .unwrap_or_else(|e| panic!("[{label}] write_batch_idempotent(page 1) errored: {e}"));
308    let after_first = count().await;
309    assert_eq!(
310        after_first - before,
311        3,
312        "[{label}] first idempotent write did not add all 3 rows"
313    );
314
315    // The token must be durably recorded — this is what lets the pipeline skip a
316    // replay. A sink that claims idempotency but never persists a token fails here.
317    let committed = sink
318        .last_committed_token(scope)
319        .await
320        .unwrap_or_else(|e| panic!("[{label}] last_committed_token errored: {e}"));
321    assert_eq!(
322        committed.as_deref(),
323        Some(t1.as_str()),
324        "[{label}] did not durably record its commit token — cannot skip a replay"
325    );
326
327    // Crash-replay of page 1: the pipeline compares the page token against the
328    // committed token and *skips* the page when already committed. Assert that
329    // decision resolves to "skip" — i.e. the sink's recorded token parses and is
330    // ≥ the page token. (We deliberately do NOT re-invoke the sink and assert the
331    // row count is unchanged: the no-duplication guarantee lives in the pipeline's
332    // skip, not in the sink, so an append-mode idempotent sink re-delivered the
333    // same committed page legitimately *would* grow. Testing that here would fail
334    // correct sinks. This is the vacuous assertion #466 L4 removed.)
335    let committed_seq = faucet_core::parse_token(committed.as_deref().unwrap_or_default())
336        .unwrap_or_else(|| panic!("[{label}] committed token {committed:?} does not parse"));
337    assert!(
338        committed_seq >= faucet_core::parse_token(&t1).unwrap_or(0),
339        "[{label}] committed token did not reach the written page's token — \
340         run_stream could not skip the replay and would re-write the page"
341    );
342
343    // Forward progress with a new token still writes.
344    let t2 = faucet_core::format_token(2);
345    let p2 = rows(&[4, 5]);
346    sink.write_batch_idempotent(&p2, scope, &t2)
347        .await
348        .unwrap_or_else(|e| panic!("[{label}] write_batch_idempotent(page 2) errored: {e}"));
349    let after_second = count().await;
350    assert_eq!(
351        after_second - after_first,
352        2,
353        "[{label}] forward progress after a new token did not add the new rows"
354    );
355}
356
357async fn assert_keyed_convergence<S, F, Fut>(sink: &S, count: &F, label: &str)
358where
359    S: Sink + ?Sized,
360    F: Fn() -> Fut,
361    Fut: std::future::Future<Output = usize>,
362{
363    let before = count().await;
364    sink.write_batch(&rows(&[1, 2, 3]))
365        .await
366        .unwrap_or_else(|e| panic!("[{label}] write_batch(page 1) errored: {e}"));
367    // Overlapping page: ids 2 and 3 are re-delivered.
368    sink.write_batch(&rows(&[2, 3, 4]))
369        .await
370        .unwrap_or_else(|e| panic!("[{label}] write_batch(overlapping page) errored: {e}"));
371    let after = count().await;
372    assert_eq!(
373        after - before,
374        4,
375        "[{label}] overlapping keys did not converge: expected 4 distinct rows (ids 1-4), \
376         got {}",
377        after - before
378    );
379}
380
381// ── Check 5: capabilities are truthful ───────────────────────────────────────
382
383/// **Check 5.** Assert a sink's advertised capabilities match real behaviour:
384/// - `Append` (always supported) actually adds rows;
385/// - a declared idempotent/keyed mechanism actually dedups (reuses check 4);
386/// - `supports_schema_evolution()` implies `evolve_schema` is callable (not the
387///   default "unsupported" error);
388/// - the honest-false branch: a non-idempotent sink's `write_batch_idempotent`
389///   delegates to `write_batch` and records no token.
390///
391/// `distinct_count` reports the destination's current distinct-row count.
392pub async fn assert_capabilities_truthful<S, F, Fut>(sink: &S, distinct_count: F)
393where
394    S: Sink + ?Sized,
395    F: Fn() -> Fut,
396    Fut: std::future::Future<Output = usize>,
397{
398    let label = sink.connector_name();
399
400    // Every sink must accept Append.
401    assert!(
402        sink.supported_write_modes()
403            .contains(&faucet_core::write_mode::WriteMode::Append),
404        "[{label}] does not advertise Append — every sink must support append"
405    );
406
407    if sink.supports_idempotent_writes() || sink.dedups_by_key() {
408        // The advertised idempotency mechanism must actually work.
409        assert_idempotent_replay(sink, &distinct_count).await;
410    } else {
411        // Honest-false: the default idempotent path must delegate, not pretend.
412        let before = distinct_count().await;
413        sink.write_batch(&rows(&[100]))
414            .await
415            .unwrap_or_else(|e| panic!("[{label}] write_batch (append probe) errored: {e}"));
416        assert_eq!(
417            distinct_count().await - before,
418            1,
419            "[{label}] Append is advertised but write_batch did not add a row"
420        );
421        assert_eq!(
422            sink.last_committed_token("conformance::honest")
423                .await
424                .unwrap_or_else(|e| panic!("[{label}] last_committed_token errored: {e}")),
425            None,
426            "[{label}] is not idempotent yet reports a committed token"
427        );
428    }
429
430    if sink.supports_schema_evolution() {
431        // A no-op evolution must be accepted (idempotent, `ADD … IF NOT EXISTS`
432        // semantics) — not the default "does not support" error.
433        let empty = faucet_core::drift::SchemaEvolution::default();
434        sink.evolve_schema(&empty).await.unwrap_or_else(|e| {
435            panic!("[{label}] advertises schema evolution but evolve_schema(no-op) errored: {e}")
436        });
437    }
438}
439
440// ── Check 6: errors, not panics ──────────────────────────────────────────────
441
442/// **Check 6.** Drive a source configured to fail (unreachable endpoint / bad
443/// config) and assert it surfaces a typed [`faucet_core::FaucetError`] **without
444/// unwinding**. Catches any panic and re-raises it as a check failure, so a
445/// connector that `unwrap()`s on bad input is caught rather than crashing the
446/// test process silently.
447///
448/// Pass a source that is *expected to fail*. Panics if the source succeeds (the
449/// failure path was not exercised) or if it panics instead of returning `Err`.
450pub async fn assert_errors_not_panics<S: Source + ?Sized>(source: &S) {
451    use futures::FutureExt;
452    let label = source.connector_name();
453
454    // `fetch_all` path.
455    let outcome = std::panic::AssertUnwindSafe(source.fetch_all())
456        .catch_unwind()
457        .await;
458    match outcome {
459        Err(_) => panic!("[{label}] panicked instead of returning Err from fetch_all"),
460        Ok(Ok(_)) => panic!("[{label}] expected a failure but fetch_all succeeded"),
461        Ok(Err(_e)) => { /* typed FaucetError, no unwind — good */ }
462    }
463
464    // `stream_pages` path — the first poll must also error (typed), not panic.
465    let ctx: HashMap<String, Value> = HashMap::new();
466    let stream_outcome = std::panic::AssertUnwindSafe(async {
467        let mut s = source.stream_pages(&ctx, 100);
468        s.next().await
469    })
470    .catch_unwind()
471    .await;
472    match stream_outcome {
473        Err(_) => panic!("[{label}] panicked instead of returning Err from stream_pages"),
474        Ok(Some(Err(_e))) => { /* typed FaucetError on first page — good */ }
475        Ok(None) => panic!("[{label}] stream_pages yielded no pages (expected an error)"),
476        Ok(Some(Ok(_))) => {
477            panic!("[{label}] expected a failure but stream_pages produced a page")
478        }
479    }
480}
481
482// ── Check 7: write modes are truthful ────────────────────────────────────────
483
484/// **Check 7.** Assert a sink advertising `Upsert`/`Delete` in
485/// [`supported_write_modes`](Sink::supported_write_modes) genuinely upholds
486/// those modes:
487/// - **Upsert** — re-writing a record with an existing key converges to one row
488///   (last-write-wins), it does not append a duplicate.
489/// - **Delete** — a record carrying the standard delete marker
490///   ([`DELETE_MARKER_FIELD`](doubles::DELETE_MARKER_FIELD) =
491///   [`DELETE_MARKER_VALUE`](doubles::DELETE_MARKER_VALUE), matching the
492///   `cdc_unwrap` convention) removes its keyed row. Only run when the sink
493///   advertises `Delete`; the sink under test must be configured with a
494///   matching `delete_marker`.
495/// - **Missing/null key** — [`plan_writes`](faucet_core::write_mode::plan_writes),
496///   the shared planner every upsert sink routes through, reports such rows as
497///   `failed` (destined for a DLQ) rather than writing them.
498///
499/// Records are keyed on `"id"`, so the sink under test must be configured
500/// `write_mode: upsert` with `key: ["id"]`. A sink advertising only `Append`
501/// has nothing to prove and the body is skipped. `distinct_count` reports the
502/// destination's current distinct-row count.
503pub async fn assert_write_modes_truthful<S, F, Fut>(sink: &S, distinct_count: F)
504where
505    S: Sink + ?Sized,
506    F: Fn() -> Fut,
507    Fut: std::future::Future<Output = usize>,
508{
509    use faucet_core::write_mode::{WriteMode, WriteSpec, plan_writes};
510
511    let label = sink.connector_name();
512    let modes = sink.supported_write_modes();
513    let has_upsert = modes.contains(&WriteMode::Upsert);
514    let has_delete = modes.contains(&WriteMode::Delete);
515
516    if !has_upsert && !has_delete {
517        // Append-only sink: nothing to demonstrate.
518        return;
519    }
520
521    // The instance under test must be *configured* for keyed writes, or there is
522    // no upsert/delete behaviour to exercise through the trait.
523    assert!(
524        sink.dedups_by_key(),
525        "[{label}] advertises {modes:?} but dedups_by_key()=false — pass a sink \
526         configured `write_mode: upsert` with `key: [\"id\"]` so the mode can be exercised"
527    );
528
529    // ── Upsert: same key twice → one row (last-write-wins), not a duplicate. ──
530    if has_upsert {
531        let before = distinct_count().await;
532        // Two distinct keys, then re-write one of them.
533        sink.write_batch(&rows(&[1, 2]))
534            .await
535            .unwrap_or_else(|e| panic!("[{label}] write_batch(upsert seed) errored: {e}"));
536        sink.write_batch(&[serde_json::json!({ "id": 1, "v": "updated" })])
537            .await
538            .unwrap_or_else(|e| panic!("[{label}] write_batch(upsert overwrite) errored: {e}"));
539        let after = distinct_count().await;
540        assert_eq!(
541            after - before,
542            2,
543            "[{label}] upsert did not converge: re-writing key id=1 left {} distinct rows \
544             (expected 2: ids 1 and 2) — it appended a duplicate instead of updating",
545            after - before
546        );
547    }
548
549    // ── Delete: a delete-marked record removes its keyed row. ──
550    if has_delete {
551        let before = distinct_count().await;
552        sink.write_batch(&[serde_json::json!({ "id": 777, "v": "doomed" })])
553            .await
554            .unwrap_or_else(|e| panic!("[{label}] write_batch(delete seed) errored: {e}"));
555        let seeded = distinct_count().await;
556        assert_eq!(
557            seeded - before,
558            1,
559            "[{label}] delete precondition failed: the row to delete was not written"
560        );
561        let mut del = serde_json::Map::new();
562        del.insert("id".to_string(), serde_json::json!(777));
563        del.insert(
564            doubles::DELETE_MARKER_FIELD.to_string(),
565            Value::String(doubles::DELETE_MARKER_VALUE.to_string()),
566        );
567        sink.write_batch(&[Value::Object(del)])
568            .await
569            .unwrap_or_else(|e| panic!("[{label}] write_batch(delete) errored: {e}"));
570        let after = distinct_count().await;
571        assert_eq!(
572            after, before,
573            "[{label}] a delete-marked record did not remove the row: {after} rows remain \
574             (expected {before}) — the delete was ignored"
575        );
576    }
577
578    // ── Missing / null key must be reported as failed, never silently written. ──
579    let spec = WriteSpec {
580        write_mode: WriteMode::Upsert,
581        key: vec!["id".to_string()],
582        delete_marker: None,
583    };
584    let plan = plan_writes(
585        &[
586            serde_json::json!({ "id": 9, "v": "ok" }),
587            serde_json::json!({ "no_key": 1 }),
588            serde_json::json!({ "id": null }),
589        ],
590        &spec,
591    );
592    assert_eq!(
593        plan.upserts.len(),
594        1,
595        "[{label}] the one keyed row should be planned as an upsert"
596    );
597    assert_eq!(
598        plan.failed.len(),
599        2,
600        "[{label}] plan_writes did not report the missing-key and null-key rows as failed \
601         (they would be silently dropped or written): {:?}",
602        plan.failed
603    );
604}
605
606// ── Check 8: schema evolution is effective ────────────────────────────────────
607
608/// **Check 8.** For a sink advertising
609/// [`supports_schema_evolution`](Sink::supports_schema_evolution): read
610/// [`current_schema`](Sink::current_schema), apply a real add-column
611/// [`SchemaEvolution`](faucet_core::drift::SchemaEvolution) via
612/// [`evolve_schema`](Sink::evolve_schema), and assert the new column appears in
613/// a *fresh* `current_schema()`. Stronger than
614/// [`assert_capabilities_truthful`], which only checks a no-op evolve does not
615/// error.
616///
617/// Panics if the sink does not advertise evolution (call it only on an evolvable
618/// sink), if `current_schema()` is `None` (nothing to diff against), or if the
619/// added column never surfaces (the evolution was a silent no-op).
620pub async fn assert_schema_evolution_effective<S: Sink + ?Sized>(sink: &S) {
621    use faucet_core::drift::{ColumnChange, SchemaEvolution};
622
623    let label = sink.connector_name();
624    assert!(
625        sink.supports_schema_evolution(),
626        "[{label}] does not advertise schema evolution — call this only on an evolvable sink \
627         (assert_capabilities_truthful covers the no-op case)"
628    );
629
630    let before = sink
631        .current_schema()
632        .await
633        .unwrap_or_else(|e| panic!("[{label}] current_schema() errored: {e}"))
634        .unwrap_or_else(|| {
635            panic!(
636                "[{label}] advertises schema evolution but current_schema() is None — \
637                 cannot verify an added column appears"
638            )
639        });
640
641    // Must be a valid identifier on every evolvable backend: Spanner rejects a
642    // leading underscore ("Column name not valid"), so no `__…__` fencing here.
643    let new_col = "faucet_conformance_evolved";
644    let already = before
645        .get("properties")
646        .and_then(|p| p.as_object())
647        .is_some_and(|p| p.contains_key(new_col));
648    assert!(
649        !already,
650        "[{label}] test column `{new_col}` already exists in current_schema() — \
651         cannot prove evolution added it"
652    );
653
654    let evolution = SchemaEvolution {
655        additions: vec![ColumnChange {
656            name: new_col.to_string(),
657            from: None,
658            to: serde_json::json!({ "type": "string" }),
659        }],
660        widenings: Vec::new(),
661        relax_nullability: Vec::new(),
662    };
663    sink.evolve_schema(&evolution)
664        .await
665        .unwrap_or_else(|e| panic!("[{label}] evolve_schema(add `{new_col}`) errored: {e}"));
666
667    let after = sink
668        .current_schema()
669        .await
670        .unwrap_or_else(|e| panic!("[{label}] current_schema() errored after evolve: {e}"))
671        .unwrap_or_else(|| panic!("[{label}] current_schema() became None after evolve_schema"));
672    let after_props = after
673        .get("properties")
674        .and_then(|p| p.as_object())
675        .unwrap_or_else(|| {
676            panic!("[{label}] current_schema() has no `properties` object after evolve: {after}")
677        });
678    assert!(
679        after_props.contains_key(new_col),
680        "[{label}] evolve_schema reported success but the added column `{new_col}` does not \
681         appear in a fresh current_schema() — the evolution was not effective: {after}"
682    );
683}
684
685// ── Check 9: batch_size=0 emits a single page ─────────────────────────────────
686
687/// **Check 9.** Drive a source **built with `batch_size = 0`** and assert it
688/// yields the entire result set as a single [`StreamPage`](faucet_core::StreamPage)
689/// — the documented "no batching" sentinel (small lookup tables, sinks that
690/// prefer one large request).
691///
692/// Asserts the source produced at least one record and that exactly one page
693/// carried records (a trailing empty terminal page carrying only a bookmark is
694/// tolerated). Panics if the data is split across multiple non-empty pages.
695pub async fn assert_batch_size_zero_single_page<S: Source + ?Sized>(source: &S) {
696    let label = source.connector_name();
697    let ctx: HashMap<String, Value> = HashMap::new();
698    let mut stream = source.stream_pages(&ctx, 0);
699    let mut pages = 0usize;
700    let mut non_empty = 0usize;
701    let mut records = 0usize;
702    while let Some(page) = stream.next().await {
703        let page = page.unwrap_or_else(|e| panic!("[{label}] stream_pages errored: {e}"));
704        pages += 1;
705        if !page.records.is_empty() {
706            non_empty += 1;
707        }
708        records += page.records.len();
709    }
710    assert!(
711        records > 0,
712        "[{label}] produced no records under batch_size=0 — cannot verify single-page batching"
713    );
714    assert_eq!(
715        non_empty, 1,
716        "[{label}] batch_size=0 must yield the entire result set as a single page, but \
717         {non_empty} non-empty pages were emitted ({pages} pages total, {records} records)"
718    );
719}
720
721// ── Check 10: connector_name is non-empty ─────────────────────────────────────
722
723/// **Check 10.** Assert a source's [`connector_name`](Source::connector_name)
724/// is a non-empty string. An empty name is a cardinality-rule violation — the
725/// observability layer falls back to the `"unknown"` metric label, silently
726/// merging distinct connectors' metrics.
727///
728/// For sinks (which expose the same method), use
729/// [`assert_connector_name_nonempty_value`]:
730/// `assert_connector_name_nonempty_value(sink.connector_name(), sink.connector_name())`.
731pub fn assert_connector_name_nonempty<S: Source + ?Sized>(source: &S) {
732    assert_connector_name_nonempty_value(source.connector_name(), source.connector_name());
733}
734
735/// The value-level core of [`assert_connector_name_nonempty`] — usable for sinks.
736pub fn assert_connector_name_nonempty_value(name: &str, label: &str) {
737    assert!(
738        !name.is_empty(),
739        "[{label}] connector_name() returned an empty string — it would surface as the \
740         \"unknown\" metric label (a cardinality-rule violation)"
741    );
742    assert!(
743        !name.trim().is_empty(),
744        "[{label}] connector_name() is whitespace-only ({name:?}) — same effect as empty"
745    );
746}
747
748// ── Check 11: preflight check() is well-formed ────────────────────────────────
749
750/// **Check 11.** Assert a source's [`check`](Source::check) returns
751/// `Ok(CheckReport)` with at least one well-formed probe (non-empty name; a
752/// `Fail`/`Skip` probe carries a non-empty reason). A connector must surface a
753/// probe failure as a [`ProbeStatus::Fail`](faucet_core::check::ProbeStatus)
754/// *inside* `Ok(report)`, never as an `Err` from `check()` (an `Err` means "no
755/// probe could run at all", which `faucet doctor` renders differently).
756///
757/// For sinks, use [`assert_sink_preflight_check_wellformed`].
758pub async fn assert_preflight_check_wellformed<S: Source + ?Sized>(
759    source: &S,
760    ctx: &faucet_core::check::CheckContext,
761) {
762    assert_report_wellformed(source.check(ctx).await, source.connector_name());
763}
764
765/// The sink counterpart of [`assert_preflight_check_wellformed`].
766pub async fn assert_sink_preflight_check_wellformed<S: Sink + ?Sized>(
767    sink: &S,
768    ctx: &faucet_core::check::CheckContext,
769) {
770    assert_report_wellformed(sink.check(ctx).await, sink.connector_name());
771}
772
773/// Shared assertion over a `check()` outcome: `Ok(report)` with well-formed
774/// probes, never `Err`.
775fn assert_report_wellformed(
776    outcome: Result<faucet_core::check::CheckReport, faucet_core::FaucetError>,
777    label: &str,
778) {
779    use faucet_core::check::ProbeStatus;
780
781    let report = outcome.unwrap_or_else(|e| {
782        panic!(
783            "[{label}] check() returned Err({e}) — a probe failure must surface as a Fail \
784             probe inside Ok(report), not as an Err from check()"
785        )
786    });
787    assert!(
788        !report.probes.is_empty(),
789        "[{label}] check() returned an empty report — a well-formed report carries at least \
790         one probe"
791    );
792    for probe in &report.probes {
793        assert!(
794            !probe.name.is_empty(),
795            "[{label}] check() returned a probe with an empty name"
796        );
797        match &probe.status {
798            ProbeStatus::Pass => {}
799            ProbeStatus::Fail { reason } => assert!(
800                !reason.trim().is_empty(),
801                "[{label}] Fail probe `{}` has an empty reason",
802                probe.name
803            ),
804            ProbeStatus::Skip { reason } => assert!(
805                !reason.trim().is_empty(),
806                "[{label}] Skip probe `{}` has an empty reason",
807                probe.name
808            ),
809        }
810    }
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816    use doubles::{
817        CountingSource, EmptyNameSource, ErringCheckSink, ErringCheckSource, EvolvingSink,
818        FailingSource, LyingIdempotentSink, LyingKeyedSink, MultiPageZeroSource, NoOpEvolvingSink,
819        PanickingSource, TestSink,
820    };
821
822    #[test]
823    fn check1_accepts_a_valid_source_schema() {
824        let s = CountingSource::new(10, 2);
825        assert_config_schema_valid(&s);
826    }
827
828    #[test]
829    fn check1_value_form_works_for_a_sink() {
830        let sink = TestSink::new();
831        assert_config_schema_valid_value(&sink.config_schema(), sink.connector_name());
832    }
833
834    #[test]
835    #[should_panic(expected = "no recognizable JSON Schema keyword")]
836    fn check1_rejects_a_non_schema() {
837        assert_config_schema_valid_value(&serde_json::json!({"nope": 1}), "bogus");
838    }
839
840    #[tokio::test]
841    async fn check2_passes_for_a_paging_source() {
842        let s = CountingSource::new(1000, 100);
843        assert_bounded_memory(&s, 100, 1000).await;
844    }
845
846    #[tokio::test]
847    #[should_panic(expected = "not bounded")]
848    async fn check2_fails_when_source_emits_one_big_page() {
849        // batch 0 => single page of `total`, which must trip the bounded check.
850        let s = CountingSource::new(500, 0);
851        assert_bounded_memory(&s, 100, 500).await;
852    }
853
854    // ── Check 3: bookmark round-trip ─────────────────────────────────────────
855
856    #[tokio::test]
857    async fn check3_passes_for_a_resumable_source() {
858        let s = CountingSource::new(500, 100);
859        assert_bookmark_roundtrip(&s).await;
860    }
861
862    #[tokio::test]
863    #[should_panic(expected = "was ignored")]
864    async fn check3_fails_when_source_ignores_the_bookmark() {
865        let s = CountingSource::non_resumable(500, 100);
866        assert_bookmark_roundtrip(&s).await;
867    }
868
869    // ── Check 4: idempotent replay ───────────────────────────────────────────
870
871    #[tokio::test]
872    async fn check4_passes_for_a_watermark_sink() {
873        let sink = TestSink::idempotent("id");
874        let s = sink.clone();
875        assert_idempotent_replay(&sink, || {
876            let s = s.clone();
877            async move { s.len() }
878        })
879        .await;
880    }
881
882    #[tokio::test]
883    async fn check4_passes_for_a_keyed_upsert_sink() {
884        let sink = TestSink::keyed("id");
885        let s = sink.clone();
886        assert_idempotent_replay(&sink, || {
887            let s = s.clone();
888            async move { s.len() }
889        })
890        .await;
891    }
892
893    #[tokio::test]
894    #[should_panic(expected = "did not durably record its commit token")]
895    async fn check4_fails_for_a_lying_idempotent_sink() {
896        let sink = LyingIdempotentSink::new();
897        let s = sink.clone();
898        assert_idempotent_replay(&sink, || {
899            let s = s.clone();
900            async move { s.len() }
901        })
902        .await;
903    }
904
905    #[tokio::test]
906    #[should_panic(expected = "did not converge")]
907    async fn check4_fails_for_a_lying_keyed_sink() {
908        let sink = LyingKeyedSink::new();
909        let s = sink.clone();
910        assert_idempotent_replay(&sink, || {
911            let s = s.clone();
912            async move { s.len() }
913        })
914        .await;
915    }
916
917    #[tokio::test]
918    #[should_panic(expected = "no idempotency mechanism")]
919    async fn check4_fails_for_an_append_only_sink() {
920        let sink = TestSink::new();
921        let s = sink.clone();
922        assert_idempotent_replay(&sink, || {
923            let s = s.clone();
924            async move { s.len() }
925        })
926        .await;
927    }
928
929    // ── Check 5: capabilities truthful ───────────────────────────────────────
930
931    #[tokio::test]
932    async fn check5_passes_for_an_honest_append_sink() {
933        let sink = TestSink::new();
934        let s = sink.clone();
935        assert_capabilities_truthful(&sink, || {
936            let s = s.clone();
937            async move { s.len() }
938        })
939        .await;
940    }
941
942    #[tokio::test]
943    async fn check5_passes_for_an_honest_idempotent_sink() {
944        let sink = TestSink::idempotent("id");
945        let s = sink.clone();
946        assert_capabilities_truthful(&sink, || {
947            let s = s.clone();
948            async move { s.len() }
949        })
950        .await;
951    }
952
953    #[tokio::test]
954    #[should_panic(expected = "did not durably record its commit token")]
955    async fn check5_fails_for_a_lying_idempotent_sink() {
956        let sink = LyingIdempotentSink::new();
957        let s = sink.clone();
958        assert_capabilities_truthful(&sink, || {
959            let s = s.clone();
960            async move { s.len() }
961        })
962        .await;
963    }
964
965    // ── Check 6: errors, not panics ──────────────────────────────────────────
966
967    #[tokio::test]
968    async fn check6_passes_for_a_source_that_returns_err() {
969        assert_errors_not_panics(&FailingSource).await;
970    }
971
972    #[tokio::test]
973    #[should_panic(expected = "panicked instead of returning Err")]
974    async fn check6_fails_for_a_source_that_panics() {
975        assert_errors_not_panics(&PanickingSource).await;
976    }
977
978    #[tokio::test]
979    #[should_panic(expected = "expected a failure but fetch_all succeeded")]
980    async fn check6_fails_for_a_source_that_succeeds() {
981        // A healthy source fed to the failure check must be flagged — the check
982        // is only meaningful against a source expected to fail.
983        assert_errors_not_panics(&CountingSource::new(3, 1)).await;
984    }
985
986    // ── Check 7: write modes truthful ────────────────────────────────────────
987
988    #[tokio::test]
989    async fn check7_passes_for_an_upsert_delete_sink() {
990        let sink = TestSink::keyed_upsert("id");
991        let s = sink.clone();
992        assert_write_modes_truthful(&sink, || {
993            let s = s.clone();
994            async move { s.len() }
995        })
996        .await;
997    }
998
999    #[tokio::test]
1000    async fn check7_skips_an_append_only_sink() {
1001        // Append-only: the body is skipped (nothing to prove), so the check
1002        // passes without ever touching the sink's write path.
1003        let sink = TestSink::new();
1004        let s = sink.clone();
1005        assert_write_modes_truthful(&sink, || {
1006            let s = s.clone();
1007            async move { s.len() }
1008        })
1009        .await;
1010        assert!(sink.is_empty(), "append-only skip must not write anything");
1011    }
1012
1013    #[tokio::test]
1014    #[should_panic(expected = "did not converge")]
1015    async fn check7_fails_for_a_lying_keyed_sink() {
1016        let sink = LyingKeyedSink::new();
1017        let s = sink.clone();
1018        assert_write_modes_truthful(&sink, || {
1019            let s = s.clone();
1020            async move { s.len() }
1021        })
1022        .await;
1023    }
1024
1025    // ── Check 8: schema evolution effective ──────────────────────────────────
1026
1027    #[tokio::test]
1028    async fn check8_passes_for_an_evolving_sink() {
1029        let sink = EvolvingSink::new();
1030        assert_schema_evolution_effective(&sink).await;
1031        assert_eq!(sink.column_count(), 2, "evolve must have added a column");
1032    }
1033
1034    #[tokio::test]
1035    #[should_panic(expected = "was not effective")]
1036    async fn check8_fails_for_a_noop_evolving_sink() {
1037        assert_schema_evolution_effective(&NoOpEvolvingSink).await;
1038    }
1039
1040    // ── Check 9: batch_size=0 single page ────────────────────────────────────
1041
1042    #[tokio::test]
1043    async fn check9_passes_for_a_single_page_source() {
1044        let s = CountingSource::new(6, 0);
1045        assert_batch_size_zero_single_page(&s).await;
1046    }
1047
1048    #[tokio::test]
1049    #[should_panic(expected = "single page")]
1050    async fn check9_fails_for_a_multi_page_source() {
1051        let s = MultiPageZeroSource::new(6);
1052        assert_batch_size_zero_single_page(&s).await;
1053    }
1054
1055    // ── Check 10: connector_name non-empty ───────────────────────────────────
1056
1057    #[test]
1058    fn check10_passes_for_a_named_source() {
1059        assert_connector_name_nonempty(&CountingSource::new(1, 1));
1060    }
1061
1062    #[test]
1063    fn check10_value_form_works_for_a_sink() {
1064        let sink = TestSink::new();
1065        assert_connector_name_nonempty_value(sink.connector_name(), sink.connector_name());
1066    }
1067
1068    #[test]
1069    #[should_panic(expected = "empty string")]
1070    fn check10_fails_for_an_empty_name_source() {
1071        assert_connector_name_nonempty(&EmptyNameSource);
1072    }
1073
1074    #[test]
1075    #[should_panic(expected = "empty string")]
1076    fn check10_value_form_rejects_empty() {
1077        assert_connector_name_nonempty_value("", "bogus");
1078    }
1079
1080    // ── Check 11: preflight check() well-formed ──────────────────────────────
1081
1082    #[tokio::test]
1083    async fn check11_passes_for_a_source_with_a_fail_probe() {
1084        // A failing source surfaces its failure as a Fail probe inside
1085        // Ok(report) — exactly what the check requires.
1086        let ctx = faucet_core::check::CheckContext::default();
1087        assert_preflight_check_wellformed(&FailingSource, &ctx).await;
1088    }
1089
1090    #[tokio::test]
1091    async fn check11_passes_for_a_healthy_source() {
1092        let ctx = faucet_core::check::CheckContext::default();
1093        assert_preflight_check_wellformed(&CountingSource::new(3, 1), &ctx).await;
1094    }
1095
1096    #[tokio::test]
1097    async fn check11_passes_for_a_sink() {
1098        let ctx = faucet_core::check::CheckContext::default();
1099        assert_sink_preflight_check_wellformed(&TestSink::new(), &ctx).await;
1100    }
1101
1102    #[tokio::test]
1103    #[should_panic(expected = "returned Err")]
1104    async fn check11_fails_when_source_check_returns_err() {
1105        let ctx = faucet_core::check::CheckContext::default();
1106        assert_preflight_check_wellformed(&ErringCheckSource, &ctx).await;
1107    }
1108
1109    #[tokio::test]
1110    #[should_panic(expected = "returned Err")]
1111    async fn check11_fails_when_sink_check_returns_err() {
1112        let ctx = faucet_core::check::CheckContext::default();
1113        assert_sink_preflight_check_wellformed(&ErringCheckSink, &ctx).await;
1114    }
1115}