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//! Integration-level checks — these drive a live backend / the real pipeline,
48//! so they belong in a connector's testcontainers/tempfile conformance test
49//! (not the synthetic unit doubles):
50//! 12. [`assert_discover_roundtrips`] — a source's `discover()` descriptors are
51//!     genuinely selectable: deep-merge each `config_patch`, rebuild, and read.
52//! 13. [`assert_cancellation_flushes`] — a mid-run cancel stops at a page
53//!     boundary and flushes the sink, so buffered output survives (#146 H16).
54//!
55//! Each check has both a passing and a `#[should_panic]` failing test in this
56//! crate — a check that cannot fail is worthless.
57
58pub mod doubles;
59
60use std::collections::HashMap;
61
62use faucet_core::{Sink, Source, Value};
63use futures::StreamExt;
64
65// ── Check 1: config schema validity ─────────────────────────────────────────
66
67/// Anything that can expose a config JSON Schema + a label — blanket-implemented
68/// for every [`Source`] so [`assert_config_schema_valid`] accepts a source
69/// directly. (Sinks can be checked via [`assert_config_schema_valid_value`].)
70pub trait HasConfigSchema {
71    /// The connector's advertised config schema.
72    fn conformance_schema(&self) -> Value;
73    /// A human label for assertion messages.
74    fn conformance_label(&self) -> String;
75}
76
77impl<T: Source + ?Sized> HasConfigSchema for T {
78    fn conformance_schema(&self) -> Value {
79        self.config_schema()
80    }
81    fn conformance_label(&self) -> String {
82        self.connector_name().to_string()
83    }
84}
85
86/// **Check 1.** Assert the connector's `config_schema()` is a structurally valid
87/// JSON Schema that round-trips through `serde_json`.
88///
89/// Panics (fails the test) on: a non-object schema, a schema with no recognized
90/// schema shape, a non-object `properties`, or a serialize→parse→serialize that
91/// is not stable.
92pub fn assert_config_schema_valid<C: HasConfigSchema + ?Sized>(connector: &C) {
93    assert_config_schema_valid_value(
94        &connector.conformance_schema(),
95        &connector.conformance_label(),
96    );
97}
98
99/// The value-level core of [`assert_config_schema_valid`] — usable for sinks:
100/// `assert_config_schema_valid_value(&sink.config_schema(), sink.connector_name())`.
101pub fn assert_config_schema_valid_value(schema: &Value, label: &str) {
102    let obj = schema.as_object().unwrap_or_else(|| {
103        panic!("[{label}] config_schema() must be a JSON object, got: {schema}")
104    });
105
106    // Recognized as *some* JSON Schema shape.
107    let recognized = [
108        "type",
109        "properties",
110        "$ref",
111        "oneOf",
112        "allOf",
113        "anyOf",
114        "$schema",
115        "enum",
116    ]
117    .iter()
118    .any(|k| obj.contains_key(*k));
119    assert!(
120        recognized,
121        "[{label}] config_schema() has no recognizable JSON Schema keyword: {schema}"
122    );
123
124    if let Some(props) = obj.get("properties") {
125        assert!(
126            props.is_object(),
127            "[{label}] config_schema().properties must be an object, got: {props}"
128        );
129    }
130    if let Some(ty) = obj.get("type") {
131        assert!(
132            ty.is_string() || ty.is_array(),
133            "[{label}] config_schema().type must be a string or array, got: {ty}"
134        );
135    }
136
137    // Round-trip: serialize → parse → serialize must be stable.
138    let text = serde_json::to_string(schema).expect("schema serializes");
139    let reparsed: Value = serde_json::from_str(&text).expect("schema re-parses");
140    assert_eq!(
141        &reparsed, schema,
142        "[{label}] config_schema() does not round-trip through serde_json"
143    );
144}
145
146// ── Check 2: bounded memory ──────────────────────────────────────────────────
147
148/// **Check 2.** Drive `stream_pages` over a source that yields `total` records
149/// and assert the consumer never holds more than ~`batch_size` records live at
150/// once (i.e. the source pages instead of buffering everything).
151///
152/// Requires `batch_size > 0` and `total > batch_size` for a meaningful result.
153/// Asserts: every record is streamed (`sum == total`), the largest single page
154/// is `<= batch_size`, and strictly `< total` (proving the source did not emit
155/// the whole set as one page).
156pub async fn assert_bounded_memory<S: Source + ?Sized>(
157    source: &S,
158    batch_size: usize,
159    total: usize,
160) {
161    assert!(
162        batch_size > 0,
163        "batch_size must be > 0 for a bounded-memory check"
164    );
165    assert!(
166        total > batch_size,
167        "total ({total}) must exceed batch_size ({batch_size}) for a meaningful check"
168    );
169    let label = source.connector_name();
170
171    let ctx: HashMap<String, Value> = HashMap::new();
172    let mut stream = source.stream_pages(&ctx, batch_size);
173    let mut seen = 0usize;
174    let mut peak = 0usize;
175    while let Some(page) = stream.next().await {
176        let page = page.unwrap_or_else(|e| panic!("[{label}] stream_pages errored: {e}"));
177        peak = peak.max(page.records.len());
178        seen += page.records.len();
179        // `page` is dropped here — the consumer only ever holds one page.
180    }
181
182    assert_eq!(
183        seen, total,
184        "[{label}] streamed {seen} records, expected {total}"
185    );
186    assert!(
187        peak <= batch_size,
188        "[{label}] peak page {peak} exceeds batch_size {batch_size} (not bounded)"
189    );
190    assert!(
191        peak < total,
192        "[{label}] peak page {peak} == total: source buffered the whole set into one page"
193    );
194}
195
196// ── Check 3: bookmark round-trip (resumable sources) ─────────────────────────
197
198/// **Check 3.** Drive an incremental source to completion, capture the bookmark
199/// it emits, feed it back via
200/// [`apply_start_bookmark`](Source::apply_start_bookmark), and assert the second
201/// run resumes *after* that point — strictly fewer records reappear (zero for a
202/// fully-consumed static source).
203///
204/// Only meaningful for a source that actually emits a bookmark and honours it.
205/// Panics if the source produces no bookmark (nothing to round-trip), or if the
206/// resumed run replays the same volume (the bookmark was ignored).
207pub async fn assert_bookmark_roundtrip<S: Source + ?Sized>(source: &S) {
208    let label = source.connector_name();
209    let ctx: HashMap<String, Value> = HashMap::new();
210
211    // First run: consume every page, remembering how many records we saw and the
212    // last non-null bookmark.
213    let (first_records, bookmark) = drain(source, &ctx, label).await;
214    assert!(
215        first_records > 0,
216        "[{label}] produced no records — cannot exercise bookmark round-trip"
217    );
218    let bookmark = bookmark.unwrap_or_else(|| {
219        panic!("[{label}] produced no bookmark to round-trip (stream_pages never set one)")
220    });
221
222    // Resume from the captured bookmark.
223    source
224        .apply_start_bookmark(bookmark.clone())
225        .await
226        .unwrap_or_else(|e| panic!("[{label}] apply_start_bookmark errored: {e}"));
227
228    let (second_records, _) = drain(source, &ctx, label).await;
229    assert!(
230        second_records < first_records,
231        "[{label}] resumed run replayed {second_records} records (first run: {first_records}); \
232         the bookmark {bookmark} was ignored — no incremental resume"
233    );
234}
235
236/// Drive `stream_pages` to completion, returning `(record_count, last_bookmark)`.
237async fn drain<S: Source + ?Sized>(
238    source: &S,
239    ctx: &HashMap<String, Value>,
240    label: &str,
241) -> (usize, Option<Value>) {
242    let mut stream = source.stream_pages(ctx, 100);
243    let mut count = 0usize;
244    let mut last_bookmark = None;
245    while let Some(page) = stream.next().await {
246        let page = page.unwrap_or_else(|e| panic!("[{label}] stream_pages errored: {e}"));
247        count += page.records.len();
248        if page.bookmark.is_some() {
249            last_bookmark = page.bookmark;
250        }
251    }
252    (count, last_bookmark)
253}
254
255// ── Check 4: idempotent replay (no duplicates on re-delivery) ─────────────────
256
257/// **Check 4.** Assert re-delivering already-committed rows leaves no
258/// duplicates in the destination — the trust-critical effectively-once check.
259///
260/// `distinct_count` returns the number of distinct rows the destination
261/// currently holds (for a double, `|| async { sink.len() }`; for a real sink, a
262/// `SELECT count(*)`). Records are keyed on the field `"id"`, so a real sink
263/// under test must be configured `write_mode: upsert` with `key: ["id"]`.
264///
265/// Dispatches on the mechanism the sink advertises:
266/// - `supports_idempotent_writes()` → the **atomic-watermark** path: writing a
267///   page durably records a commit token; a crash-replay (guarded by
268///   `last_committed_token`, exactly as the pipeline guards it) does not
269///   re-write, and forward progress still advances.
270/// - else `dedups_by_key()` → the **keyed-upsert** path: overlapping keys across
271///   pages converge to one row each.
272/// - neither → panics (the sink advertises no idempotency mechanism to test).
273pub async fn assert_idempotent_replay<S, F, Fut>(sink: &S, distinct_count: F)
274where
275    S: Sink + ?Sized,
276    F: Fn() -> Fut,
277    Fut: std::future::Future<Output = usize>,
278{
279    let label = sink.connector_name();
280    if sink.supports_idempotent_writes() {
281        assert_watermark_idempotent(sink, &distinct_count, label).await;
282    } else if sink.dedups_by_key() {
283        assert_keyed_convergence(sink, &distinct_count, label).await;
284    } else {
285        panic!(
286            "[{label}] advertises no idempotency mechanism \
287             (supports_idempotent_writes=false, dedups_by_key=false) — nothing to verify"
288        );
289    }
290}
291
292/// Build test records keyed on `"id"` with a non-key `"v"` column, so a SQL
293/// upsert (`ON CONFLICT(id) DO UPDATE SET v = …`) has something to set — a
294/// single key-only column would produce an empty SET clause.
295fn rows(ids: &[i64]) -> Vec<Value> {
296    ids.iter()
297        .map(|i| serde_json::json!({ "id": i, "v": format!("v{i}") }))
298        .collect()
299}
300
301async fn assert_watermark_idempotent<S, F, Fut>(sink: &S, count: &F, label: &str)
302where
303    S: Sink + ?Sized,
304    F: Fn() -> Fut,
305    Fut: std::future::Future<Output = usize>,
306{
307    let scope = "conformance::idem";
308    let before = count().await;
309
310    // Page 1 with the first commit token.
311    let t1 = faucet_core::format_token(1);
312    let p1 = rows(&[1, 2, 3]);
313    sink.write_batch_idempotent(&p1, scope, &t1)
314        .await
315        .unwrap_or_else(|e| panic!("[{label}] write_batch_idempotent(page 1) errored: {e}"));
316    let after_first = count().await;
317    assert_eq!(
318        after_first - before,
319        3,
320        "[{label}] first idempotent write did not add all 3 rows"
321    );
322
323    // The token must be durably recorded — this is what lets the pipeline skip a
324    // replay. A sink that claims idempotency but never persists a token fails here.
325    let committed = sink
326        .last_committed_token(scope)
327        .await
328        .unwrap_or_else(|e| panic!("[{label}] last_committed_token errored: {e}"));
329    assert_eq!(
330        committed.as_deref(),
331        Some(t1.as_str()),
332        "[{label}] did not durably record its commit token — cannot skip a replay"
333    );
334
335    // Crash-replay of page 1: the pipeline compares the page token against the
336    // committed token and *skips* the page when already committed. Assert that
337    // decision resolves to "skip" — i.e. the sink's recorded token parses and is
338    // ≥ the page token. (We deliberately do NOT re-invoke the sink and assert the
339    // row count is unchanged: the no-duplication guarantee lives in the pipeline's
340    // skip, not in the sink, so an append-mode idempotent sink re-delivered the
341    // same committed page legitimately *would* grow. Testing that here would fail
342    // correct sinks. This is the vacuous assertion #466 L4 removed.)
343    let committed_seq = faucet_core::parse_token(committed.as_deref().unwrap_or_default())
344        .unwrap_or_else(|| panic!("[{label}] committed token {committed:?} does not parse"));
345    assert!(
346        committed_seq >= faucet_core::parse_token(&t1).unwrap_or(0),
347        "[{label}] committed token did not reach the written page's token — \
348         run_stream could not skip the replay and would re-write the page"
349    );
350
351    // Forward progress with a new token still writes.
352    let t2 = faucet_core::format_token(2);
353    let p2 = rows(&[4, 5]);
354    sink.write_batch_idempotent(&p2, scope, &t2)
355        .await
356        .unwrap_or_else(|e| panic!("[{label}] write_batch_idempotent(page 2) errored: {e}"));
357    let after_second = count().await;
358    assert_eq!(
359        after_second - after_first,
360        2,
361        "[{label}] forward progress after a new token did not add the new rows"
362    );
363}
364
365async fn assert_keyed_convergence<S, F, Fut>(sink: &S, count: &F, label: &str)
366where
367    S: Sink + ?Sized,
368    F: Fn() -> Fut,
369    Fut: std::future::Future<Output = usize>,
370{
371    let before = count().await;
372    sink.write_batch(&rows(&[1, 2, 3]))
373        .await
374        .unwrap_or_else(|e| panic!("[{label}] write_batch(page 1) errored: {e}"));
375    // Overlapping page: ids 2 and 3 are re-delivered.
376    sink.write_batch(&rows(&[2, 3, 4]))
377        .await
378        .unwrap_or_else(|e| panic!("[{label}] write_batch(overlapping page) errored: {e}"));
379    let after = count().await;
380    assert_eq!(
381        after - before,
382        4,
383        "[{label}] overlapping keys did not converge: expected 4 distinct rows (ids 1-4), \
384         got {}",
385        after - before
386    );
387}
388
389// ── Check 5: capabilities are truthful ───────────────────────────────────────
390
391/// **Check 5.** Assert a sink's advertised capabilities match real behaviour:
392/// - `Append` (always supported) actually adds rows;
393/// - a declared idempotent/keyed mechanism actually dedups (reuses check 4);
394/// - `supports_schema_evolution()` implies `evolve_schema` is callable (not the
395///   default "unsupported" error);
396/// - the honest-false branch: a non-idempotent sink's `write_batch_idempotent`
397///   delegates to `write_batch` and records no token.
398///
399/// `distinct_count` reports the destination's current distinct-row count.
400pub async fn assert_capabilities_truthful<S, F, Fut>(sink: &S, distinct_count: F)
401where
402    S: Sink + ?Sized,
403    F: Fn() -> Fut,
404    Fut: std::future::Future<Output = usize>,
405{
406    let label = sink.connector_name();
407
408    // Every sink must accept Append.
409    assert!(
410        sink.supported_write_modes()
411            .contains(&faucet_core::write_mode::WriteMode::Append),
412        "[{label}] does not advertise Append — every sink must support append"
413    );
414
415    if sink.supports_idempotent_writes() || sink.dedups_by_key() {
416        // The advertised idempotency mechanism must actually work.
417        assert_idempotent_replay(sink, &distinct_count).await;
418    } else {
419        // Honest-false: the default idempotent path must delegate, not pretend.
420        let before = distinct_count().await;
421        sink.write_batch(&rows(&[100]))
422            .await
423            .unwrap_or_else(|e| panic!("[{label}] write_batch (append probe) errored: {e}"));
424        assert_eq!(
425            distinct_count().await - before,
426            1,
427            "[{label}] Append is advertised but write_batch did not add a row"
428        );
429        assert_eq!(
430            sink.last_committed_token("conformance::honest")
431                .await
432                .unwrap_or_else(|e| panic!("[{label}] last_committed_token errored: {e}")),
433            None,
434            "[{label}] is not idempotent yet reports a committed token"
435        );
436    }
437
438    if sink.supports_schema_evolution() {
439        // A no-op evolution must be accepted (idempotent, `ADD … IF NOT EXISTS`
440        // semantics) — not the default "does not support" error.
441        let empty = faucet_core::drift::SchemaEvolution::default();
442        sink.evolve_schema(&empty).await.unwrap_or_else(|e| {
443            panic!("[{label}] advertises schema evolution but evolve_schema(no-op) errored: {e}")
444        });
445    }
446}
447
448// ── Check 6: errors, not panics ──────────────────────────────────────────────
449
450/// **Check 6.** Drive a source configured to fail (unreachable endpoint / bad
451/// config) and assert it surfaces a typed [`faucet_core::FaucetError`] **without
452/// unwinding**. Catches any panic and re-raises it as a check failure, so a
453/// connector that `unwrap()`s on bad input is caught rather than crashing the
454/// test process silently.
455///
456/// Pass a source that is *expected to fail*. Panics if the source succeeds (the
457/// failure path was not exercised) or if it panics instead of returning `Err`.
458pub async fn assert_errors_not_panics<S: Source + ?Sized>(source: &S) {
459    use futures::FutureExt;
460    let label = source.connector_name();
461
462    // `fetch_all` path.
463    let outcome = std::panic::AssertUnwindSafe(source.fetch_all())
464        .catch_unwind()
465        .await;
466    match outcome {
467        Err(_) => panic!("[{label}] panicked instead of returning Err from fetch_all"),
468        Ok(Ok(_)) => panic!("[{label}] expected a failure but fetch_all succeeded"),
469        Ok(Err(_e)) => { /* typed FaucetError, no unwind — good */ }
470    }
471
472    // `stream_pages` path — the first poll must also error (typed), not panic.
473    let ctx: HashMap<String, Value> = HashMap::new();
474    let stream_outcome = std::panic::AssertUnwindSafe(async {
475        let mut s = source.stream_pages(&ctx, 100);
476        s.next().await
477    })
478    .catch_unwind()
479    .await;
480    match stream_outcome {
481        Err(_) => panic!("[{label}] panicked instead of returning Err from stream_pages"),
482        Ok(Some(Err(_e))) => { /* typed FaucetError on first page — good */ }
483        Ok(None) => panic!("[{label}] stream_pages yielded no pages (expected an error)"),
484        Ok(Some(Ok(_))) => {
485            panic!("[{label}] expected a failure but stream_pages produced a page")
486        }
487    }
488}
489
490// ── Check 7: write modes are truthful ────────────────────────────────────────
491
492/// **Check 7.** Assert a sink advertising `Upsert`/`Delete` in
493/// [`supported_write_modes`](Sink::supported_write_modes) genuinely upholds
494/// those modes:
495/// - **Upsert** — re-writing a record with an existing key converges to one row
496///   (last-write-wins), it does not append a duplicate.
497/// - **Delete** — a record carrying the standard delete marker
498///   ([`DELETE_MARKER_FIELD`](doubles::DELETE_MARKER_FIELD) =
499///   [`DELETE_MARKER_VALUE`](doubles::DELETE_MARKER_VALUE), matching the
500///   `cdc_unwrap` convention) removes its keyed row. Only run when the sink
501///   advertises `Delete`; the sink under test must be configured with a
502///   matching `delete_marker`.
503/// - **Missing/null key** — [`plan_writes`](faucet_core::write_mode::plan_writes),
504///   the shared planner every upsert sink routes through, reports such rows as
505///   `failed` (destined for a DLQ) rather than writing them.
506///
507/// Records are keyed on `"id"`, so the sink under test must be configured
508/// `write_mode: upsert` with `key: ["id"]`. A sink advertising only `Append`
509/// has nothing to prove and the body is skipped. `distinct_count` reports the
510/// destination's current distinct-row count.
511pub async fn assert_write_modes_truthful<S, F, Fut>(sink: &S, distinct_count: F)
512where
513    S: Sink + ?Sized,
514    F: Fn() -> Fut,
515    Fut: std::future::Future<Output = usize>,
516{
517    use faucet_core::write_mode::{WriteMode, WriteSpec, plan_writes};
518
519    let label = sink.connector_name();
520    let modes = sink.supported_write_modes();
521    let has_upsert = modes.contains(&WriteMode::Upsert);
522    let has_delete = modes.contains(&WriteMode::Delete);
523
524    if !has_upsert && !has_delete {
525        // Append-only sink: nothing to demonstrate.
526        return;
527    }
528
529    // The instance under test must be *configured* for keyed writes, or there is
530    // no upsert/delete behaviour to exercise through the trait.
531    assert!(
532        sink.dedups_by_key(),
533        "[{label}] advertises {modes:?} but dedups_by_key()=false — pass a sink \
534         configured `write_mode: upsert` with `key: [\"id\"]` so the mode can be exercised"
535    );
536
537    // ── Upsert: same key twice → one row (last-write-wins), not a duplicate. ──
538    if has_upsert {
539        let before = distinct_count().await;
540        // Two distinct keys, then re-write one of them.
541        sink.write_batch(&rows(&[1, 2]))
542            .await
543            .unwrap_or_else(|e| panic!("[{label}] write_batch(upsert seed) errored: {e}"));
544        sink.write_batch(&[serde_json::json!({ "id": 1, "v": "updated" })])
545            .await
546            .unwrap_or_else(|e| panic!("[{label}] write_batch(upsert overwrite) errored: {e}"));
547        let after = distinct_count().await;
548        assert_eq!(
549            after - before,
550            2,
551            "[{label}] upsert did not converge: re-writing key id=1 left {} distinct rows \
552             (expected 2: ids 1 and 2) — it appended a duplicate instead of updating",
553            after - before
554        );
555    }
556
557    // ── Delete: a delete-marked record removes its keyed row. ──
558    if has_delete {
559        let before = distinct_count().await;
560        sink.write_batch(&[serde_json::json!({ "id": 777, "v": "doomed" })])
561            .await
562            .unwrap_or_else(|e| panic!("[{label}] write_batch(delete seed) errored: {e}"));
563        let seeded = distinct_count().await;
564        assert_eq!(
565            seeded - before,
566            1,
567            "[{label}] delete precondition failed: the row to delete was not written"
568        );
569        let mut del = serde_json::Map::new();
570        del.insert("id".to_string(), serde_json::json!(777));
571        del.insert(
572            doubles::DELETE_MARKER_FIELD.to_string(),
573            Value::String(doubles::DELETE_MARKER_VALUE.to_string()),
574        );
575        sink.write_batch(&[Value::Object(del)])
576            .await
577            .unwrap_or_else(|e| panic!("[{label}] write_batch(delete) errored: {e}"));
578        let after = distinct_count().await;
579        assert_eq!(
580            after, before,
581            "[{label}] a delete-marked record did not remove the row: {after} rows remain \
582             (expected {before}) — the delete was ignored"
583        );
584    }
585
586    // ── Missing / null key must be reported as failed, never silently written. ──
587    let spec = WriteSpec {
588        write_mode: WriteMode::Upsert,
589        key: vec!["id".to_string()],
590        delete_marker: None,
591    };
592    let plan = plan_writes(
593        &[
594            serde_json::json!({ "id": 9, "v": "ok" }),
595            serde_json::json!({ "no_key": 1 }),
596            serde_json::json!({ "id": null }),
597        ],
598        &spec,
599    );
600    assert_eq!(
601        plan.upserts.len(),
602        1,
603        "[{label}] the one keyed row should be planned as an upsert"
604    );
605    assert_eq!(
606        plan.failed.len(),
607        2,
608        "[{label}] plan_writes did not report the missing-key and null-key rows as failed \
609         (they would be silently dropped or written): {:?}",
610        plan.failed
611    );
612}
613
614// ── Check 8: schema evolution is effective ────────────────────────────────────
615
616/// **Check 8.** For a sink advertising
617/// [`supports_schema_evolution`](Sink::supports_schema_evolution): read
618/// [`current_schema`](Sink::current_schema), apply a real add-column
619/// [`SchemaEvolution`](faucet_core::drift::SchemaEvolution) via
620/// [`evolve_schema`](Sink::evolve_schema), and assert the new column appears in
621/// a *fresh* `current_schema()`. Stronger than
622/// [`assert_capabilities_truthful`], which only checks a no-op evolve does not
623/// error.
624///
625/// Panics if the sink does not advertise evolution (call it only on an evolvable
626/// sink), if `current_schema()` is `None` (nothing to diff against), or if the
627/// added column never surfaces (the evolution was a silent no-op).
628pub async fn assert_schema_evolution_effective<S: Sink + ?Sized>(sink: &S) {
629    use faucet_core::drift::{ColumnChange, SchemaEvolution};
630
631    let label = sink.connector_name();
632    assert!(
633        sink.supports_schema_evolution(),
634        "[{label}] does not advertise schema evolution — call this only on an evolvable sink \
635         (assert_capabilities_truthful covers the no-op case)"
636    );
637
638    let before = sink
639        .current_schema()
640        .await
641        .unwrap_or_else(|e| panic!("[{label}] current_schema() errored: {e}"))
642        .unwrap_or_else(|| {
643            panic!(
644                "[{label}] advertises schema evolution but current_schema() is None — \
645                 cannot verify an added column appears"
646            )
647        });
648
649    // Must be a valid identifier on every evolvable backend: Spanner rejects a
650    // leading underscore ("Column name not valid"), so no `__…__` fencing here.
651    let new_col = "faucet_conformance_evolved";
652    let already = before
653        .get("properties")
654        .and_then(|p| p.as_object())
655        .is_some_and(|p| p.contains_key(new_col));
656    assert!(
657        !already,
658        "[{label}] test column `{new_col}` already exists in current_schema() — \
659         cannot prove evolution added it"
660    );
661
662    let evolution = SchemaEvolution {
663        additions: vec![ColumnChange {
664            name: new_col.to_string(),
665            from: None,
666            to: serde_json::json!({ "type": "string" }),
667        }],
668        widenings: Vec::new(),
669        relax_nullability: Vec::new(),
670    };
671    sink.evolve_schema(&evolution)
672        .await
673        .unwrap_or_else(|e| panic!("[{label}] evolve_schema(add `{new_col}`) errored: {e}"));
674
675    let after = sink
676        .current_schema()
677        .await
678        .unwrap_or_else(|e| panic!("[{label}] current_schema() errored after evolve: {e}"))
679        .unwrap_or_else(|| panic!("[{label}] current_schema() became None after evolve_schema"));
680    let after_props = after
681        .get("properties")
682        .and_then(|p| p.as_object())
683        .unwrap_or_else(|| {
684            panic!("[{label}] current_schema() has no `properties` object after evolve: {after}")
685        });
686    assert!(
687        after_props.contains_key(new_col),
688        "[{label}] evolve_schema reported success but the added column `{new_col}` does not \
689         appear in a fresh current_schema() — the evolution was not effective: {after}"
690    );
691}
692
693// ── Check 9: batch_size=0 emits a single page ─────────────────────────────────
694
695/// **Check 9.** Drive a source **built with `batch_size = 0`** and assert it
696/// yields the entire result set as a single [`StreamPage`](faucet_core::StreamPage)
697/// — the documented "no batching" sentinel (small lookup tables, sinks that
698/// prefer one large request).
699///
700/// Asserts the source produced at least one record and that exactly one page
701/// carried records (a trailing empty terminal page carrying only a bookmark is
702/// tolerated). Panics if the data is split across multiple non-empty pages.
703pub async fn assert_batch_size_zero_single_page<S: Source + ?Sized>(source: &S) {
704    let label = source.connector_name();
705    let ctx: HashMap<String, Value> = HashMap::new();
706    let mut stream = source.stream_pages(&ctx, 0);
707    let mut pages = 0usize;
708    let mut non_empty = 0usize;
709    let mut records = 0usize;
710    while let Some(page) = stream.next().await {
711        let page = page.unwrap_or_else(|e| panic!("[{label}] stream_pages errored: {e}"));
712        pages += 1;
713        if !page.records.is_empty() {
714            non_empty += 1;
715        }
716        records += page.records.len();
717    }
718    assert!(
719        records > 0,
720        "[{label}] produced no records under batch_size=0 — cannot verify single-page batching"
721    );
722    assert_eq!(
723        non_empty, 1,
724        "[{label}] batch_size=0 must yield the entire result set as a single page, but \
725         {non_empty} non-empty pages were emitted ({pages} pages total, {records} records)"
726    );
727}
728
729// ── Check 10: connector_name is non-empty ─────────────────────────────────────
730
731/// **Check 10.** Assert a source's [`connector_name`](Source::connector_name)
732/// is a non-empty string. An empty name is a cardinality-rule violation — the
733/// observability layer falls back to the `"unknown"` metric label, silently
734/// merging distinct connectors' metrics.
735///
736/// For sinks (which expose the same method), use
737/// [`assert_connector_name_nonempty_value`]:
738/// `assert_connector_name_nonempty_value(sink.connector_name(), sink.connector_name())`.
739pub fn assert_connector_name_nonempty<S: Source + ?Sized>(source: &S) {
740    assert_connector_name_nonempty_value(source.connector_name(), source.connector_name());
741}
742
743/// The value-level core of [`assert_connector_name_nonempty`] — usable for sinks.
744pub fn assert_connector_name_nonempty_value(name: &str, label: &str) {
745    assert!(
746        !name.is_empty(),
747        "[{label}] connector_name() returned an empty string — it would surface as the \
748         \"unknown\" metric label (a cardinality-rule violation)"
749    );
750    assert!(
751        !name.trim().is_empty(),
752        "[{label}] connector_name() is whitespace-only ({name:?}) — same effect as empty"
753    );
754}
755
756// ── Check 11: preflight check() is well-formed ────────────────────────────────
757
758/// **Check 11.** Assert a source's [`check`](Source::check) returns
759/// `Ok(CheckReport)` with at least one well-formed probe (non-empty name; a
760/// `Fail`/`Skip` probe carries a non-empty reason). A connector must surface a
761/// probe failure as a [`ProbeStatus::Fail`](faucet_core::check::ProbeStatus)
762/// *inside* `Ok(report)`, never as an `Err` from `check()` (an `Err` means "no
763/// probe could run at all", which `faucet doctor` renders differently).
764///
765/// For sinks, use [`assert_sink_preflight_check_wellformed`].
766pub async fn assert_preflight_check_wellformed<S: Source + ?Sized>(
767    source: &S,
768    ctx: &faucet_core::check::CheckContext,
769) {
770    assert_report_wellformed(source.check(ctx).await, source.connector_name());
771}
772
773/// The sink counterpart of [`assert_preflight_check_wellformed`].
774pub async fn assert_sink_preflight_check_wellformed<S: Sink + ?Sized>(
775    sink: &S,
776    ctx: &faucet_core::check::CheckContext,
777) {
778    assert_report_wellformed(sink.check(ctx).await, sink.connector_name());
779}
780
781/// Shared assertion over a `check()` outcome: `Ok(report)` with well-formed
782/// probes, never `Err`.
783fn assert_report_wellformed(
784    outcome: Result<faucet_core::check::CheckReport, faucet_core::FaucetError>,
785    label: &str,
786) {
787    use faucet_core::check::ProbeStatus;
788
789    let report = outcome.unwrap_or_else(|e| {
790        panic!(
791            "[{label}] check() returned Err({e}) — a probe failure must surface as a Fail \
792             probe inside Ok(report), not as an Err from check()"
793        )
794    });
795    assert!(
796        !report.probes.is_empty(),
797        "[{label}] check() returned an empty report — a well-formed report carries at least \
798         one probe"
799    );
800    for probe in &report.probes {
801        assert!(
802            !probe.name.is_empty(),
803            "[{label}] check() returned a probe with an empty name"
804        );
805        match &probe.status {
806            ProbeStatus::Pass => {}
807            ProbeStatus::Fail { reason } => assert!(
808                !reason.trim().is_empty(),
809                "[{label}] Fail probe `{}` has an empty reason",
810                probe.name
811            ),
812            ProbeStatus::Skip { reason } => assert!(
813                !reason.trim().is_empty(),
814                "[{label}] Skip probe `{}` has an empty reason",
815                probe.name
816            ),
817        }
818    }
819}
820
821// ── Check 12: discovery round-trips (discoverable sources) ────────────────────
822
823/// Deep-merge a discovery `config_patch` onto a base config `Value` and return
824/// the merged config — objects merge recursively, scalars and arrays replace
825/// wholesale. This mirrors the deep-merge the CLI applies when it turns a
826/// [`DatasetDescriptor`](faucet_core::DatasetDescriptor) into a matrix row, so a
827/// `rebuild` closure for [`assert_discover_roundtrips`] can compose the patch
828/// onto the real base config exactly as production does:
829///
830/// ```
831/// use faucet_conformance::merge_config_patch;
832/// use serde_json::json;
833/// let base = json!({ "url": "…", "query": "SELECT 1" });
834/// let merged = merge_config_patch(base, &json!({ "query": "SELECT * FROM t" }));
835/// assert_eq!(merged["query"], "SELECT * FROM t");
836/// assert_eq!(merged["url"], "…");
837/// ```
838pub fn merge_config_patch(mut base: Value, patch: &Value) -> Value {
839    merge_into(&mut base, patch);
840    base
841}
842
843fn merge_into(base: &mut Value, patch: &Value) {
844    match (base, patch) {
845        (Value::Object(base_map), Value::Object(patch_map)) => {
846            for (k, v) in patch_map {
847                merge_into(base_map.entry(k.clone()).or_insert(Value::Null), v);
848            }
849        }
850        (base_slot, patch) => *base_slot = patch.clone(),
851    }
852}
853
854/// **Check 12.** For a source that advertises
855/// [`supports_discover`](Source::supports_discover): enumerate its datasets via
856/// [`discover`](Source::discover), then for each descriptor deep-merge its
857/// [`config_patch`](faucet_core::DatasetDescriptor::config_patch) onto the base
858/// config (via the caller's `rebuild` closure) and assert the rebuilt source is
859/// actually readable — the dataset the catalog advertised is genuinely
860/// selectable end-to-end, not just a name in a list.
861///
862/// This is an **integration-level** check: it needs a live, seeded backend so
863/// `discover()` returns real descriptors and the rebuilt source reads real (or
864/// legitimately empty) data. Run it in a connector's testcontainers/tempfile
865/// conformance test, reusing the already-seeded backend. `rebuild` receives the
866/// descriptor's `config_patch` and returns the source built from
867/// `base config ⊕ patch` — [`merge_config_patch`] writes that closure in one
868/// line.
869///
870/// Asserts: the source advertises discovery; `discover()` returns at least one
871/// descriptor (a seeded backend must expose something — an empty catalog makes
872/// the round-trip vacuous); and every rebuilt source drains through
873/// `stream_pages` **without error** (≥ 0 rows — the selected dataset may be
874/// legitimately empty, but selecting it must not fail).
875pub async fn assert_discover_roundtrips<S, F, Fut>(source: &S, rebuild: F)
876where
877    S: Source + ?Sized,
878    F: Fn(Value) -> Fut,
879    Fut: std::future::Future<Output = Box<dyn Source>>,
880{
881    let label = source.connector_name();
882    assert!(
883        source.supports_discover(),
884        "[{label}] does not advertise discovery (supports_discover()=false) — call this only \
885         on a discoverable source"
886    );
887
888    let descriptors = source
889        .discover()
890        .await
891        .unwrap_or_else(|e| panic!("[{label}] discover() errored: {e}"));
892    assert!(
893        !descriptors.is_empty(),
894        "[{label}] discover() returned no datasets — seed the backend before the round-trip so \
895         there is a real dataset to re-select (an empty catalog makes the check vacuous)"
896    );
897
898    for descriptor in &descriptors {
899        let patch = descriptor.config_patch.clone();
900        let rebuilt = rebuild(patch.clone()).await;
901        // Drive the real read path of the rebuilt source. The dataset selected
902        // by the patch must be readable; it may legitimately hold zero rows.
903        let ctx: HashMap<String, Value> = HashMap::new();
904        let mut stream = rebuilt.stream_pages(&ctx, 100);
905        while let Some(page) = stream.next().await {
906            page.unwrap_or_else(|e| {
907                panic!(
908                    "[{label}] rebuilt source for dataset `{}` (config_patch {patch}) errored on \
909                     read: {e} — the descriptor the catalog advertised is not actually selectable",
910                    descriptor.name
911                )
912            });
913        }
914    }
915}
916
917// ── Check 13: cancellation flushes buffered output ────────────────────────────
918
919/// **Check 13.** Assert the flush-completing cancellation contract
920/// ([ADR 0011](https://github.com/faucet-hq/faucet-stream/blob/main/docs/adr/0011-cooperative-cancellation.md),
921/// #146 H16): a [`CancellationToken`](faucet_core::CancellationToken) fired
922/// mid-run stops [`run_stream`](faucet_core::run_stream) at the next page
923/// boundary, **flushes the sink** so buffered output is made durable, and
924/// returns `Ok` with the partial result — as opposed to dropping the run
925/// future, which would flush nothing and orphan a buffered sink's output (a
926/// Parquet footer, an S3 multipart).
927///
928/// Drives the **real** `faucet_core::run_stream` with a synthetic source that
929/// yields one page and then cancels the token (deterministic — no timers, no
930/// flakiness). `durable_count` reports the number of rows the sink has made
931/// **durable** (for [`BufferedSink`](doubles::BufferedSink),
932/// `|| async { sink.durable_len() }`; for a real buffered sink, whatever a
933/// reader observes *after* the run). Asserts `run_stream` returned `Ok`, the
934/// partial result counts the written page, and every written row is durable —
935/// i.e. the cancel path flushed. A sink whose `flush` does not commit fails
936/// here, as does a pipeline that skips the on-cancel flush.
937pub async fn assert_cancellation_flushes<S, F, Fut>(sink: &S, durable_count: F)
938where
939    S: Sink + ?Sized,
940    F: Fn() -> Fut,
941    Fut: std::future::Future<Output = usize>,
942{
943    use faucet_core::{CancellationToken, RunStreamOptions, StreamPage, run_stream};
944
945    let label = sink.connector_name();
946    let before = durable_count().await;
947
948    let page = rows(&[1, 2, 3]);
949    let n = page.len();
950
951    let token = CancellationToken::new();
952    let stream_token = token.clone();
953    // Yield one page, then fire the token and block. The only way `run_stream`
954    // exits is the cooperative cancel at the page boundary — so the flush it
955    // performs there is exactly the #146 H16 behaviour under test.
956    let stream = Box::pin(async_stream::stream! {
957        yield Ok(StreamPage { records: page, bookmark: None });
958        stream_token.cancel();
959        futures::future::pending::<()>().await;
960    });
961
962    let result = run_stream(stream, sink, RunStreamOptions::new().with_cancel(token))
963        .await
964        .unwrap_or_else(|e| {
965            panic!(
966                "[{label}] a cooperative cancel must return Ok with the partial result, got \
967                 Err({e})"
968            )
969        });
970    assert_eq!(
971        result.records_written, n,
972        "[{label}] the page written before cancellation is not counted in the partial result"
973    );
974
975    let durable = durable_count().await - before;
976    assert_eq!(
977        durable, n,
978        "[{label}] the sink was not flushed on the cancel path: {durable} of {n} written rows \
979         are durable — buffered output would be lost when a run is cancelled"
980    );
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986    use doubles::{
987        BufferedSink, CountingSource, DiscoverableSource, EmptyNameSource, ErringCheckSink,
988        ErringCheckSource, EvolvingSink, FailingSource, LyingIdempotentSink, LyingKeyedSink,
989        MultiPageZeroSource, NoOpEvolvingSink, PanickingSource, TestSink,
990    };
991
992    #[test]
993    fn check1_accepts_a_valid_source_schema() {
994        let s = CountingSource::new(10, 2);
995        assert_config_schema_valid(&s);
996    }
997
998    #[test]
999    fn check1_value_form_works_for_a_sink() {
1000        let sink = TestSink::new();
1001        assert_config_schema_valid_value(&sink.config_schema(), sink.connector_name());
1002    }
1003
1004    #[test]
1005    #[should_panic(expected = "no recognizable JSON Schema keyword")]
1006    fn check1_rejects_a_non_schema() {
1007        assert_config_schema_valid_value(&serde_json::json!({"nope": 1}), "bogus");
1008    }
1009
1010    #[tokio::test]
1011    async fn check2_passes_for_a_paging_source() {
1012        let s = CountingSource::new(1000, 100);
1013        assert_bounded_memory(&s, 100, 1000).await;
1014    }
1015
1016    #[tokio::test]
1017    #[should_panic(expected = "not bounded")]
1018    async fn check2_fails_when_source_emits_one_big_page() {
1019        // batch 0 => single page of `total`, which must trip the bounded check.
1020        let s = CountingSource::new(500, 0);
1021        assert_bounded_memory(&s, 100, 500).await;
1022    }
1023
1024    // ── Check 3: bookmark round-trip ─────────────────────────────────────────
1025
1026    #[tokio::test]
1027    async fn check3_passes_for_a_resumable_source() {
1028        let s = CountingSource::new(500, 100);
1029        assert_bookmark_roundtrip(&s).await;
1030    }
1031
1032    #[tokio::test]
1033    #[should_panic(expected = "was ignored")]
1034    async fn check3_fails_when_source_ignores_the_bookmark() {
1035        let s = CountingSource::non_resumable(500, 100);
1036        assert_bookmark_roundtrip(&s).await;
1037    }
1038
1039    // ── Check 4: idempotent replay ───────────────────────────────────────────
1040
1041    #[tokio::test]
1042    async fn check4_passes_for_a_watermark_sink() {
1043        let sink = TestSink::idempotent("id");
1044        let s = sink.clone();
1045        assert_idempotent_replay(&sink, || {
1046            let s = s.clone();
1047            async move { s.len() }
1048        })
1049        .await;
1050    }
1051
1052    #[tokio::test]
1053    async fn check4_passes_for_a_keyed_upsert_sink() {
1054        let sink = TestSink::keyed("id");
1055        let s = sink.clone();
1056        assert_idempotent_replay(&sink, || {
1057            let s = s.clone();
1058            async move { s.len() }
1059        })
1060        .await;
1061    }
1062
1063    #[tokio::test]
1064    #[should_panic(expected = "did not durably record its commit token")]
1065    async fn check4_fails_for_a_lying_idempotent_sink() {
1066        let sink = LyingIdempotentSink::new();
1067        let s = sink.clone();
1068        assert_idempotent_replay(&sink, || {
1069            let s = s.clone();
1070            async move { s.len() }
1071        })
1072        .await;
1073    }
1074
1075    #[tokio::test]
1076    #[should_panic(expected = "did not converge")]
1077    async fn check4_fails_for_a_lying_keyed_sink() {
1078        let sink = LyingKeyedSink::new();
1079        let s = sink.clone();
1080        assert_idempotent_replay(&sink, || {
1081            let s = s.clone();
1082            async move { s.len() }
1083        })
1084        .await;
1085    }
1086
1087    #[tokio::test]
1088    #[should_panic(expected = "no idempotency mechanism")]
1089    async fn check4_fails_for_an_append_only_sink() {
1090        let sink = TestSink::new();
1091        let s = sink.clone();
1092        assert_idempotent_replay(&sink, || {
1093            let s = s.clone();
1094            async move { s.len() }
1095        })
1096        .await;
1097    }
1098
1099    // ── Check 5: capabilities truthful ───────────────────────────────────────
1100
1101    #[tokio::test]
1102    async fn check5_passes_for_an_honest_append_sink() {
1103        let sink = TestSink::new();
1104        let s = sink.clone();
1105        assert_capabilities_truthful(&sink, || {
1106            let s = s.clone();
1107            async move { s.len() }
1108        })
1109        .await;
1110    }
1111
1112    #[tokio::test]
1113    async fn check5_passes_for_an_honest_idempotent_sink() {
1114        let sink = TestSink::idempotent("id");
1115        let s = sink.clone();
1116        assert_capabilities_truthful(&sink, || {
1117            let s = s.clone();
1118            async move { s.len() }
1119        })
1120        .await;
1121    }
1122
1123    #[tokio::test]
1124    #[should_panic(expected = "did not durably record its commit token")]
1125    async fn check5_fails_for_a_lying_idempotent_sink() {
1126        let sink = LyingIdempotentSink::new();
1127        let s = sink.clone();
1128        assert_capabilities_truthful(&sink, || {
1129            let s = s.clone();
1130            async move { s.len() }
1131        })
1132        .await;
1133    }
1134
1135    // ── Check 6: errors, not panics ──────────────────────────────────────────
1136
1137    #[tokio::test]
1138    async fn check6_passes_for_a_source_that_returns_err() {
1139        assert_errors_not_panics(&FailingSource).await;
1140    }
1141
1142    #[tokio::test]
1143    #[should_panic(expected = "panicked instead of returning Err")]
1144    async fn check6_fails_for_a_source_that_panics() {
1145        assert_errors_not_panics(&PanickingSource).await;
1146    }
1147
1148    #[tokio::test]
1149    #[should_panic(expected = "expected a failure but fetch_all succeeded")]
1150    async fn check6_fails_for_a_source_that_succeeds() {
1151        // A healthy source fed to the failure check must be flagged — the check
1152        // is only meaningful against a source expected to fail.
1153        assert_errors_not_panics(&CountingSource::new(3, 1)).await;
1154    }
1155
1156    // ── Check 7: write modes truthful ────────────────────────────────────────
1157
1158    #[tokio::test]
1159    async fn check7_passes_for_an_upsert_delete_sink() {
1160        let sink = TestSink::keyed_upsert("id");
1161        let s = sink.clone();
1162        assert_write_modes_truthful(&sink, || {
1163            let s = s.clone();
1164            async move { s.len() }
1165        })
1166        .await;
1167    }
1168
1169    #[tokio::test]
1170    async fn check7_skips_an_append_only_sink() {
1171        // Append-only: the body is skipped (nothing to prove), so the check
1172        // passes without ever touching the sink's write path.
1173        let sink = TestSink::new();
1174        let s = sink.clone();
1175        assert_write_modes_truthful(&sink, || {
1176            let s = s.clone();
1177            async move { s.len() }
1178        })
1179        .await;
1180        assert!(sink.is_empty(), "append-only skip must not write anything");
1181    }
1182
1183    #[tokio::test]
1184    #[should_panic(expected = "did not converge")]
1185    async fn check7_fails_for_a_lying_keyed_sink() {
1186        let sink = LyingKeyedSink::new();
1187        let s = sink.clone();
1188        assert_write_modes_truthful(&sink, || {
1189            let s = s.clone();
1190            async move { s.len() }
1191        })
1192        .await;
1193    }
1194
1195    // ── Check 8: schema evolution effective ──────────────────────────────────
1196
1197    #[tokio::test]
1198    async fn check8_passes_for_an_evolving_sink() {
1199        let sink = EvolvingSink::new();
1200        assert_schema_evolution_effective(&sink).await;
1201        assert_eq!(sink.column_count(), 2, "evolve must have added a column");
1202    }
1203
1204    #[tokio::test]
1205    #[should_panic(expected = "was not effective")]
1206    async fn check8_fails_for_a_noop_evolving_sink() {
1207        assert_schema_evolution_effective(&NoOpEvolvingSink).await;
1208    }
1209
1210    // ── Check 9: batch_size=0 single page ────────────────────────────────────
1211
1212    #[tokio::test]
1213    async fn check9_passes_for_a_single_page_source() {
1214        let s = CountingSource::new(6, 0);
1215        assert_batch_size_zero_single_page(&s).await;
1216    }
1217
1218    #[tokio::test]
1219    #[should_panic(expected = "single page")]
1220    async fn check9_fails_for_a_multi_page_source() {
1221        let s = MultiPageZeroSource::new(6);
1222        assert_batch_size_zero_single_page(&s).await;
1223    }
1224
1225    // ── Check 10: connector_name non-empty ───────────────────────────────────
1226
1227    #[test]
1228    fn check10_passes_for_a_named_source() {
1229        assert_connector_name_nonempty(&CountingSource::new(1, 1));
1230    }
1231
1232    #[test]
1233    fn check10_value_form_works_for_a_sink() {
1234        let sink = TestSink::new();
1235        assert_connector_name_nonempty_value(sink.connector_name(), sink.connector_name());
1236    }
1237
1238    #[test]
1239    #[should_panic(expected = "empty string")]
1240    fn check10_fails_for_an_empty_name_source() {
1241        assert_connector_name_nonempty(&EmptyNameSource);
1242    }
1243
1244    #[test]
1245    #[should_panic(expected = "empty string")]
1246    fn check10_value_form_rejects_empty() {
1247        assert_connector_name_nonempty_value("", "bogus");
1248    }
1249
1250    // ── Check 11: preflight check() well-formed ──────────────────────────────
1251
1252    #[tokio::test]
1253    async fn check11_passes_for_a_source_with_a_fail_probe() {
1254        // A failing source surfaces its failure as a Fail probe inside
1255        // Ok(report) — exactly what the check requires.
1256        let ctx = faucet_core::check::CheckContext::default();
1257        assert_preflight_check_wellformed(&FailingSource, &ctx).await;
1258    }
1259
1260    #[tokio::test]
1261    async fn check11_passes_for_a_healthy_source() {
1262        let ctx = faucet_core::check::CheckContext::default();
1263        assert_preflight_check_wellformed(&CountingSource::new(3, 1), &ctx).await;
1264    }
1265
1266    #[tokio::test]
1267    async fn check11_passes_for_a_sink() {
1268        let ctx = faucet_core::check::CheckContext::default();
1269        assert_sink_preflight_check_wellformed(&TestSink::new(), &ctx).await;
1270    }
1271
1272    #[tokio::test]
1273    #[should_panic(expected = "returned Err")]
1274    async fn check11_fails_when_source_check_returns_err() {
1275        let ctx = faucet_core::check::CheckContext::default();
1276        assert_preflight_check_wellformed(&ErringCheckSource, &ctx).await;
1277    }
1278
1279    #[tokio::test]
1280    #[should_panic(expected = "returned Err")]
1281    async fn check11_fails_when_sink_check_returns_err() {
1282        let ctx = faucet_core::check::CheckContext::default();
1283        assert_sink_preflight_check_wellformed(&ErringCheckSink, &ctx).await;
1284    }
1285
1286    // ── merge_config_patch ────────────────────────────────────────────────────
1287
1288    #[test]
1289    fn merge_config_patch_is_recursive_with_scalar_and_array_replace() {
1290        let base = serde_json::json!({
1291            "url": "keep",
1292            "query": "SELECT 1",
1293            "opts": { "a": 1, "b": 2 },
1294            "keys": [1, 2, 3],
1295        });
1296        let merged = merge_config_patch(
1297            base,
1298            &serde_json::json!({
1299                "query": "SELECT * FROM t",   // scalar replace
1300                "opts": { "b": 9, "c": 3 },   // recursive object merge
1301                "keys": [7],                    // array replaces wholesale
1302            }),
1303        );
1304        assert_eq!(merged["url"], "keep");
1305        assert_eq!(merged["query"], "SELECT * FROM t");
1306        assert_eq!(
1307            merged["opts"],
1308            serde_json::json!({ "a": 1, "b": 9, "c": 3 })
1309        );
1310        assert_eq!(merged["keys"], serde_json::json!([7]));
1311    }
1312
1313    // ── Check 12: discovery round-trips ──────────────────────────────────────
1314
1315    #[tokio::test]
1316    async fn check12_passes_when_every_dataset_rebuilds_and_reads() {
1317        let source = DiscoverableSource::new();
1318        assert_discover_roundtrips(&source, |patch| async move {
1319            // The patch selects a dataset; a real adopter would deep-merge it
1320            // onto the base config and rebuild. Here the rebuilt source just
1321            // reads a small, healthy set.
1322            let name = patch["dataset"].as_str().unwrap_or("");
1323            assert!(!name.is_empty(), "config_patch must carry the dataset");
1324            Box::new(CountingSource::new(3, 1)) as Box<dyn Source>
1325        })
1326        .await;
1327    }
1328
1329    #[tokio::test]
1330    #[should_panic(expected = "does not advertise discovery")]
1331    async fn check12_fails_for_a_non_discoverable_source() {
1332        let source = CountingSource::new(3, 1);
1333        assert_discover_roundtrips(&source, |_patch| async {
1334            Box::new(CountingSource::new(3, 1)) as Box<dyn Source>
1335        })
1336        .await;
1337    }
1338
1339    #[tokio::test]
1340    #[should_panic(expected = "returned no datasets")]
1341    async fn check12_fails_when_catalog_is_empty() {
1342        let source = DiscoverableSource::empty();
1343        assert_discover_roundtrips(&source, |_patch| async {
1344            Box::new(CountingSource::new(3, 1)) as Box<dyn Source>
1345        })
1346        .await;
1347    }
1348
1349    #[tokio::test]
1350    #[should_panic(expected = "errored on read")]
1351    async fn check12_fails_when_rebuilt_source_is_unreadable() {
1352        let source = DiscoverableSource::new();
1353        assert_discover_roundtrips(&source, |_patch| async {
1354            // The catalog advertised a dataset the rebuilt source can't read.
1355            Box::new(FailingSource) as Box<dyn Source>
1356        })
1357        .await;
1358    }
1359
1360    // ── Check 13: cancellation flushes ───────────────────────────────────────
1361
1362    #[tokio::test]
1363    async fn check13_passes_for_a_sink_that_flushes_on_cancel() {
1364        let sink = BufferedSink::new();
1365        let s = sink.clone();
1366        assert_cancellation_flushes(&sink, || {
1367            let s = s.clone();
1368            async move { s.durable_len() }
1369        })
1370        .await;
1371        // The written page was flushed to durable storage on the cancel path.
1372        assert_eq!(sink.durable_len(), 3);
1373        assert_eq!(sink.staged_len(), 0);
1374    }
1375
1376    #[tokio::test]
1377    #[should_panic(expected = "was not flushed on the cancel path")]
1378    async fn check13_fails_for_a_sink_whose_flush_drops_the_buffer() {
1379        let sink = BufferedSink::broken();
1380        let s = sink.clone();
1381        assert_cancellation_flushes(&sink, || {
1382            let s = s.clone();
1383            async move { s.durable_len() }
1384        })
1385        .await;
1386    }
1387}