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//! All six checks are fully implemented:
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//! Each check has both a passing and a `#[should_panic]` failing test in this
34//! crate — a check that cannot fail is worthless.
35
36pub mod doubles;
37
38use std::collections::HashMap;
39
40use faucet_core::{Sink, Source, Value};
41use futures::StreamExt;
42
43// ── Check 1: config schema validity ─────────────────────────────────────────
44
45/// Anything that can expose a config JSON Schema + a label — blanket-implemented
46/// for every [`Source`] so [`assert_config_schema_valid`] accepts a source
47/// directly. (Sinks can be checked via [`assert_config_schema_valid_value`].)
48pub trait HasConfigSchema {
49    /// The connector's advertised config schema.
50    fn conformance_schema(&self) -> Value;
51    /// A human label for assertion messages.
52    fn conformance_label(&self) -> String;
53}
54
55impl<T: Source + ?Sized> HasConfigSchema for T {
56    fn conformance_schema(&self) -> Value {
57        self.config_schema()
58    }
59    fn conformance_label(&self) -> String {
60        self.connector_name().to_string()
61    }
62}
63
64/// **Check 1.** Assert the connector's `config_schema()` is a structurally valid
65/// JSON Schema that round-trips through `serde_json`.
66///
67/// Panics (fails the test) on: a non-object schema, a schema with no recognized
68/// schema shape, a non-object `properties`, or a serialize→parse→serialize that
69/// is not stable.
70pub fn assert_config_schema_valid<C: HasConfigSchema + ?Sized>(connector: &C) {
71    assert_config_schema_valid_value(
72        &connector.conformance_schema(),
73        &connector.conformance_label(),
74    );
75}
76
77/// The value-level core of [`assert_config_schema_valid`] — usable for sinks:
78/// `assert_config_schema_valid_value(&sink.config_schema(), sink.connector_name())`.
79pub fn assert_config_schema_valid_value(schema: &Value, label: &str) {
80    let obj = schema.as_object().unwrap_or_else(|| {
81        panic!("[{label}] config_schema() must be a JSON object, got: {schema}")
82    });
83
84    // Recognized as *some* JSON Schema shape.
85    let recognized = [
86        "type",
87        "properties",
88        "$ref",
89        "oneOf",
90        "allOf",
91        "anyOf",
92        "$schema",
93        "enum",
94    ]
95    .iter()
96    .any(|k| obj.contains_key(*k));
97    assert!(
98        recognized,
99        "[{label}] config_schema() has no recognizable JSON Schema keyword: {schema}"
100    );
101
102    if let Some(props) = obj.get("properties") {
103        assert!(
104            props.is_object(),
105            "[{label}] config_schema().properties must be an object, got: {props}"
106        );
107    }
108    if let Some(ty) = obj.get("type") {
109        assert!(
110            ty.is_string() || ty.is_array(),
111            "[{label}] config_schema().type must be a string or array, got: {ty}"
112        );
113    }
114
115    // Round-trip: serialize → parse → serialize must be stable.
116    let text = serde_json::to_string(schema).expect("schema serializes");
117    let reparsed: Value = serde_json::from_str(&text).expect("schema re-parses");
118    assert_eq!(
119        &reparsed, schema,
120        "[{label}] config_schema() does not round-trip through serde_json"
121    );
122}
123
124// ── Check 2: bounded memory ──────────────────────────────────────────────────
125
126/// **Check 2.** Drive `stream_pages` over a source that yields `total` records
127/// and assert the consumer never holds more than ~`batch_size` records live at
128/// once (i.e. the source pages instead of buffering everything).
129///
130/// Requires `batch_size > 0` and `total > batch_size` for a meaningful result.
131/// Asserts: every record is streamed (`sum == total`), the largest single page
132/// is `<= batch_size`, and strictly `< total` (proving the source did not emit
133/// the whole set as one page).
134pub async fn assert_bounded_memory<S: Source + ?Sized>(
135    source: &S,
136    batch_size: usize,
137    total: usize,
138) {
139    assert!(
140        batch_size > 0,
141        "batch_size must be > 0 for a bounded-memory check"
142    );
143    assert!(
144        total > batch_size,
145        "total ({total}) must exceed batch_size ({batch_size}) for a meaningful check"
146    );
147    let label = source.connector_name();
148
149    let ctx: HashMap<String, Value> = HashMap::new();
150    let mut stream = source.stream_pages(&ctx, batch_size);
151    let mut seen = 0usize;
152    let mut peak = 0usize;
153    while let Some(page) = stream.next().await {
154        let page = page.unwrap_or_else(|e| panic!("[{label}] stream_pages errored: {e}"));
155        peak = peak.max(page.records.len());
156        seen += page.records.len();
157        // `page` is dropped here — the consumer only ever holds one page.
158    }
159
160    assert_eq!(
161        seen, total,
162        "[{label}] streamed {seen} records, expected {total}"
163    );
164    assert!(
165        peak <= batch_size,
166        "[{label}] peak page {peak} exceeds batch_size {batch_size} (not bounded)"
167    );
168    assert!(
169        peak < total,
170        "[{label}] peak page {peak} == total: source buffered the whole set into one page"
171    );
172}
173
174// ── Check 3: bookmark round-trip (resumable sources) ─────────────────────────
175
176/// **Check 3.** Drive an incremental source to completion, capture the bookmark
177/// it emits, feed it back via
178/// [`apply_start_bookmark`](Source::apply_start_bookmark), and assert the second
179/// run resumes *after* that point — strictly fewer records reappear (zero for a
180/// fully-consumed static source).
181///
182/// Only meaningful for a source that actually emits a bookmark and honours it.
183/// Panics if the source produces no bookmark (nothing to round-trip), or if the
184/// resumed run replays the same volume (the bookmark was ignored).
185pub async fn assert_bookmark_roundtrip<S: Source + ?Sized>(source: &S) {
186    let label = source.connector_name();
187    let ctx: HashMap<String, Value> = HashMap::new();
188
189    // First run: consume every page, remembering how many records we saw and the
190    // last non-null bookmark.
191    let (first_records, bookmark) = drain(source, &ctx, label).await;
192    assert!(
193        first_records > 0,
194        "[{label}] produced no records — cannot exercise bookmark round-trip"
195    );
196    let bookmark = bookmark.unwrap_or_else(|| {
197        panic!("[{label}] produced no bookmark to round-trip (stream_pages never set one)")
198    });
199
200    // Resume from the captured bookmark.
201    source
202        .apply_start_bookmark(bookmark.clone())
203        .await
204        .unwrap_or_else(|e| panic!("[{label}] apply_start_bookmark errored: {e}"));
205
206    let (second_records, _) = drain(source, &ctx, label).await;
207    assert!(
208        second_records < first_records,
209        "[{label}] resumed run replayed {second_records} records (first run: {first_records}); \
210         the bookmark {bookmark} was ignored — no incremental resume"
211    );
212}
213
214/// Drive `stream_pages` to completion, returning `(record_count, last_bookmark)`.
215async fn drain<S: Source + ?Sized>(
216    source: &S,
217    ctx: &HashMap<String, Value>,
218    label: &str,
219) -> (usize, Option<Value>) {
220    let mut stream = source.stream_pages(ctx, 100);
221    let mut count = 0usize;
222    let mut last_bookmark = None;
223    while let Some(page) = stream.next().await {
224        let page = page.unwrap_or_else(|e| panic!("[{label}] stream_pages errored: {e}"));
225        count += page.records.len();
226        if page.bookmark.is_some() {
227            last_bookmark = page.bookmark;
228        }
229    }
230    (count, last_bookmark)
231}
232
233// ── Check 4: idempotent replay (no duplicates on re-delivery) ─────────────────
234
235/// **Check 4.** Assert re-delivering already-committed rows leaves no
236/// duplicates in the destination — the trust-critical effectively-once check.
237///
238/// `distinct_count` returns the number of distinct rows the destination
239/// currently holds (for a double, `|| async { sink.len() }`; for a real sink, a
240/// `SELECT count(*)`). Records are keyed on the field `"id"`, so a real sink
241/// under test must be configured `write_mode: upsert` with `key: ["id"]`.
242///
243/// Dispatches on the mechanism the sink advertises:
244/// - `supports_idempotent_writes()` → the **atomic-watermark** path: writing a
245///   page durably records a commit token; a crash-replay (guarded by
246///   `last_committed_token`, exactly as the pipeline guards it) does not
247///   re-write, and forward progress still advances.
248/// - else `dedups_by_key()` → the **keyed-upsert** path: overlapping keys across
249///   pages converge to one row each.
250/// - neither → panics (the sink advertises no idempotency mechanism to test).
251pub async fn assert_idempotent_replay<S, F, Fut>(sink: &S, distinct_count: F)
252where
253    S: Sink + ?Sized,
254    F: Fn() -> Fut,
255    Fut: std::future::Future<Output = usize>,
256{
257    let label = sink.connector_name();
258    if sink.supports_idempotent_writes() {
259        assert_watermark_idempotent(sink, &distinct_count, label).await;
260    } else if sink.dedups_by_key() {
261        assert_keyed_convergence(sink, &distinct_count, label).await;
262    } else {
263        panic!(
264            "[{label}] advertises no idempotency mechanism \
265             (supports_idempotent_writes=false, dedups_by_key=false) — nothing to verify"
266        );
267    }
268}
269
270/// Build test records keyed on `"id"` with a non-key `"v"` column, so a SQL
271/// upsert (`ON CONFLICT(id) DO UPDATE SET v = …`) has something to set — a
272/// single key-only column would produce an empty SET clause.
273fn rows(ids: &[i64]) -> Vec<Value> {
274    ids.iter()
275        .map(|i| serde_json::json!({ "id": i, "v": format!("v{i}") }))
276        .collect()
277}
278
279async fn assert_watermark_idempotent<S, F, Fut>(sink: &S, count: &F, label: &str)
280where
281    S: Sink + ?Sized,
282    F: Fn() -> Fut,
283    Fut: std::future::Future<Output = usize>,
284{
285    let scope = "conformance::idem";
286    let before = count().await;
287
288    // Page 1 with the first commit token.
289    let t1 = faucet_core::format_token(1);
290    let p1 = rows(&[1, 2, 3]);
291    sink.write_batch_idempotent(&p1, scope, &t1)
292        .await
293        .unwrap_or_else(|e| panic!("[{label}] write_batch_idempotent(page 1) errored: {e}"));
294    let after_first = count().await;
295    assert_eq!(
296        after_first - before,
297        3,
298        "[{label}] first idempotent write did not add all 3 rows"
299    );
300
301    // The token must be durably recorded — this is what lets the pipeline skip a
302    // replay. A sink that claims idempotency but never persists a token fails here.
303    let committed = sink
304        .last_committed_token(scope)
305        .await
306        .unwrap_or_else(|e| panic!("[{label}] last_committed_token errored: {e}"));
307    assert_eq!(
308        committed.as_deref(),
309        Some(t1.as_str()),
310        "[{label}] did not durably record its commit token — cannot skip a replay"
311    );
312
313    // Crash-replay of page 1: the pipeline compares the page token against the
314    // committed token and skips when already committed. Mimic that guard; the
315    // destination must not grow.
316    if faucet_core::parse_token(committed.as_deref().unwrap_or_default())
317        .is_some_and(|c| c >= faucet_core::parse_token(&t1).unwrap_or(0))
318    {
319        // committed >= page token ⇒ skip (no re-write), exactly as run_stream does.
320    } else {
321        panic!("[{label}] committed token did not advance to the written page's token");
322    }
323    let after_replay = count().await;
324    assert_eq!(
325        after_replay, after_first,
326        "[{label}] a guarded replay changed the destination — watermark is not honoured"
327    );
328
329    // Forward progress with a new token still writes.
330    let t2 = faucet_core::format_token(2);
331    let p2 = rows(&[4, 5]);
332    sink.write_batch_idempotent(&p2, scope, &t2)
333        .await
334        .unwrap_or_else(|e| panic!("[{label}] write_batch_idempotent(page 2) errored: {e}"));
335    let after_second = count().await;
336    assert_eq!(
337        after_second - after_first,
338        2,
339        "[{label}] forward progress after a new token did not add the new rows"
340    );
341}
342
343async fn assert_keyed_convergence<S, F, Fut>(sink: &S, count: &F, label: &str)
344where
345    S: Sink + ?Sized,
346    F: Fn() -> Fut,
347    Fut: std::future::Future<Output = usize>,
348{
349    let before = count().await;
350    sink.write_batch(&rows(&[1, 2, 3]))
351        .await
352        .unwrap_or_else(|e| panic!("[{label}] write_batch(page 1) errored: {e}"));
353    // Overlapping page: ids 2 and 3 are re-delivered.
354    sink.write_batch(&rows(&[2, 3, 4]))
355        .await
356        .unwrap_or_else(|e| panic!("[{label}] write_batch(overlapping page) errored: {e}"));
357    let after = count().await;
358    assert_eq!(
359        after - before,
360        4,
361        "[{label}] overlapping keys did not converge: expected 4 distinct rows (ids 1-4), \
362         got {}",
363        after - before
364    );
365}
366
367// ── Check 5: capabilities are truthful ───────────────────────────────────────
368
369/// **Check 5.** Assert a sink's advertised capabilities match real behaviour:
370/// - `Append` (always supported) actually adds rows;
371/// - a declared idempotent/keyed mechanism actually dedups (reuses check 4);
372/// - `supports_schema_evolution()` implies `evolve_schema` is callable (not the
373///   default "unsupported" error);
374/// - the honest-false branch: a non-idempotent sink's `write_batch_idempotent`
375///   delegates to `write_batch` and records no token.
376///
377/// `distinct_count` reports the destination's current distinct-row count.
378pub async fn assert_capabilities_truthful<S, F, Fut>(sink: &S, distinct_count: F)
379where
380    S: Sink + ?Sized,
381    F: Fn() -> Fut,
382    Fut: std::future::Future<Output = usize>,
383{
384    let label = sink.connector_name();
385
386    // Every sink must accept Append.
387    assert!(
388        sink.supported_write_modes()
389            .contains(&faucet_core::write_mode::WriteMode::Append),
390        "[{label}] does not advertise Append — every sink must support append"
391    );
392
393    if sink.supports_idempotent_writes() || sink.dedups_by_key() {
394        // The advertised idempotency mechanism must actually work.
395        assert_idempotent_replay(sink, &distinct_count).await;
396    } else {
397        // Honest-false: the default idempotent path must delegate, not pretend.
398        let before = distinct_count().await;
399        sink.write_batch(&rows(&[100]))
400            .await
401            .unwrap_or_else(|e| panic!("[{label}] write_batch (append probe) errored: {e}"));
402        assert_eq!(
403            distinct_count().await - before,
404            1,
405            "[{label}] Append is advertised but write_batch did not add a row"
406        );
407        assert_eq!(
408            sink.last_committed_token("conformance::honest")
409                .await
410                .unwrap_or_else(|e| panic!("[{label}] last_committed_token errored: {e}")),
411            None,
412            "[{label}] is not idempotent yet reports a committed token"
413        );
414    }
415
416    if sink.supports_schema_evolution() {
417        // A no-op evolution must be accepted (idempotent, `ADD … IF NOT EXISTS`
418        // semantics) — not the default "does not support" error.
419        let empty = faucet_core::drift::SchemaEvolution::default();
420        sink.evolve_schema(&empty).await.unwrap_or_else(|e| {
421            panic!("[{label}] advertises schema evolution but evolve_schema(no-op) errored: {e}")
422        });
423    }
424}
425
426// ── Check 6: errors, not panics ──────────────────────────────────────────────
427
428/// **Check 6.** Drive a source configured to fail (unreachable endpoint / bad
429/// config) and assert it surfaces a typed [`faucet_core::FaucetError`] **without
430/// unwinding**. Catches any panic and re-raises it as a check failure, so a
431/// connector that `unwrap()`s on bad input is caught rather than crashing the
432/// test process silently.
433///
434/// Pass a source that is *expected to fail*. Panics if the source succeeds (the
435/// failure path was not exercised) or if it panics instead of returning `Err`.
436pub async fn assert_errors_not_panics<S: Source + ?Sized>(source: &S) {
437    use futures::FutureExt;
438    let label = source.connector_name();
439
440    // `fetch_all` path.
441    let outcome = std::panic::AssertUnwindSafe(source.fetch_all())
442        .catch_unwind()
443        .await;
444    match outcome {
445        Err(_) => panic!("[{label}] panicked instead of returning Err from fetch_all"),
446        Ok(Ok(_)) => panic!("[{label}] expected a failure but fetch_all succeeded"),
447        Ok(Err(_e)) => { /* typed FaucetError, no unwind — good */ }
448    }
449
450    // `stream_pages` path — the first poll must also error (typed), not panic.
451    let ctx: HashMap<String, Value> = HashMap::new();
452    let stream_outcome = std::panic::AssertUnwindSafe(async {
453        let mut s = source.stream_pages(&ctx, 100);
454        s.next().await
455    })
456    .catch_unwind()
457    .await;
458    match stream_outcome {
459        Err(_) => panic!("[{label}] panicked instead of returning Err from stream_pages"),
460        Ok(Some(Err(_e))) => { /* typed FaucetError on first page — good */ }
461        Ok(None) => panic!("[{label}] stream_pages yielded no pages (expected an error)"),
462        Ok(Some(Ok(_))) => {
463            panic!("[{label}] expected a failure but stream_pages produced a page")
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use doubles::{
472        CountingSource, FailingSource, LyingIdempotentSink, LyingKeyedSink, PanickingSource,
473        TestSink,
474    };
475
476    #[test]
477    fn check1_accepts_a_valid_source_schema() {
478        let s = CountingSource::new(10, 2);
479        assert_config_schema_valid(&s);
480    }
481
482    #[test]
483    fn check1_value_form_works_for_a_sink() {
484        let sink = TestSink::new();
485        assert_config_schema_valid_value(&sink.config_schema(), sink.connector_name());
486    }
487
488    #[test]
489    #[should_panic(expected = "no recognizable JSON Schema keyword")]
490    fn check1_rejects_a_non_schema() {
491        assert_config_schema_valid_value(&serde_json::json!({"nope": 1}), "bogus");
492    }
493
494    #[tokio::test]
495    async fn check2_passes_for_a_paging_source() {
496        let s = CountingSource::new(1000, 100);
497        assert_bounded_memory(&s, 100, 1000).await;
498    }
499
500    #[tokio::test]
501    #[should_panic(expected = "not bounded")]
502    async fn check2_fails_when_source_emits_one_big_page() {
503        // batch 0 => single page of `total`, which must trip the bounded check.
504        let s = CountingSource::new(500, 0);
505        assert_bounded_memory(&s, 100, 500).await;
506    }
507
508    // ── Check 3: bookmark round-trip ─────────────────────────────────────────
509
510    #[tokio::test]
511    async fn check3_passes_for_a_resumable_source() {
512        let s = CountingSource::new(500, 100);
513        assert_bookmark_roundtrip(&s).await;
514    }
515
516    #[tokio::test]
517    #[should_panic(expected = "was ignored")]
518    async fn check3_fails_when_source_ignores_the_bookmark() {
519        let s = CountingSource::non_resumable(500, 100);
520        assert_bookmark_roundtrip(&s).await;
521    }
522
523    // ── Check 4: idempotent replay ───────────────────────────────────────────
524
525    #[tokio::test]
526    async fn check4_passes_for_a_watermark_sink() {
527        let sink = TestSink::idempotent("id");
528        let s = sink.clone();
529        assert_idempotent_replay(&sink, || {
530            let s = s.clone();
531            async move { s.len() }
532        })
533        .await;
534    }
535
536    #[tokio::test]
537    async fn check4_passes_for_a_keyed_upsert_sink() {
538        let sink = TestSink::keyed("id");
539        let s = sink.clone();
540        assert_idempotent_replay(&sink, || {
541            let s = s.clone();
542            async move { s.len() }
543        })
544        .await;
545    }
546
547    #[tokio::test]
548    #[should_panic(expected = "did not durably record its commit token")]
549    async fn check4_fails_for_a_lying_idempotent_sink() {
550        let sink = LyingIdempotentSink::new();
551        let s = sink.clone();
552        assert_idempotent_replay(&sink, || {
553            let s = s.clone();
554            async move { s.len() }
555        })
556        .await;
557    }
558
559    #[tokio::test]
560    #[should_panic(expected = "did not converge")]
561    async fn check4_fails_for_a_lying_keyed_sink() {
562        let sink = LyingKeyedSink::new();
563        let s = sink.clone();
564        assert_idempotent_replay(&sink, || {
565            let s = s.clone();
566            async move { s.len() }
567        })
568        .await;
569    }
570
571    #[tokio::test]
572    #[should_panic(expected = "no idempotency mechanism")]
573    async fn check4_fails_for_an_append_only_sink() {
574        let sink = TestSink::new();
575        let s = sink.clone();
576        assert_idempotent_replay(&sink, || {
577            let s = s.clone();
578            async move { s.len() }
579        })
580        .await;
581    }
582
583    // ── Check 5: capabilities truthful ───────────────────────────────────────
584
585    #[tokio::test]
586    async fn check5_passes_for_an_honest_append_sink() {
587        let sink = TestSink::new();
588        let s = sink.clone();
589        assert_capabilities_truthful(&sink, || {
590            let s = s.clone();
591            async move { s.len() }
592        })
593        .await;
594    }
595
596    #[tokio::test]
597    async fn check5_passes_for_an_honest_idempotent_sink() {
598        let sink = TestSink::idempotent("id");
599        let s = sink.clone();
600        assert_capabilities_truthful(&sink, || {
601            let s = s.clone();
602            async move { s.len() }
603        })
604        .await;
605    }
606
607    #[tokio::test]
608    #[should_panic(expected = "did not durably record its commit token")]
609    async fn check5_fails_for_a_lying_idempotent_sink() {
610        let sink = LyingIdempotentSink::new();
611        let s = sink.clone();
612        assert_capabilities_truthful(&sink, || {
613            let s = s.clone();
614            async move { s.len() }
615        })
616        .await;
617    }
618
619    // ── Check 6: errors, not panics ──────────────────────────────────────────
620
621    #[tokio::test]
622    async fn check6_passes_for_a_source_that_returns_err() {
623        assert_errors_not_panics(&FailingSource).await;
624    }
625
626    #[tokio::test]
627    #[should_panic(expected = "panicked instead of returning Err")]
628    async fn check6_fails_for_a_source_that_panics() {
629        assert_errors_not_panics(&PanickingSource).await;
630    }
631
632    #[tokio::test]
633    #[should_panic(expected = "expected a failure but fetch_all succeeded")]
634    async fn check6_fails_for_a_source_that_succeeds() {
635        // A healthy source fed to the failure check must be flagged — the check
636        // is only meaningful against a source expected to fail.
637        assert_errors_not_panics(&CountingSource::new(3, 1)).await;
638    }
639}