1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3pub mod doubles;
59
60use std::collections::HashMap;
61
62use faucet_core::{Sink, Source, Value};
63use futures::StreamExt;
64
65pub trait HasConfigSchema {
71 fn conformance_schema(&self) -> Value;
73 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
86pub 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
99pub 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 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 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
146pub 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 }
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
196pub 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 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 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
236async 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
255pub 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
292fn 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 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 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 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 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 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
389pub 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 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 assert_idempotent_replay(sink, &distinct_count).await;
418 } else {
419 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 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
448pub async fn assert_errors_not_panics<S: Source + ?Sized>(source: &S) {
459 use futures::FutureExt;
460 let label = source.connector_name();
461
462 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)) => { }
470 }
471
472 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))) => { }
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
490pub 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 return;
527 }
528
529 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 if has_upsert {
539 let before = distinct_count().await;
540 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 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 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
614pub 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 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
693pub 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
729pub fn assert_connector_name_nonempty<S: Source + ?Sized>(source: &S) {
740 assert_connector_name_nonempty_value(source.connector_name(), source.connector_name());
741}
742
743pub 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
756pub 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
773pub 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
781fn 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
821pub 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
854pub 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 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
917pub 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 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 let s = CountingSource::new(500, 0);
1021 assert_bounded_memory(&s, 100, 500).await;
1022 }
1023
1024 #[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 #[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 #[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 #[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 assert_errors_not_panics(&CountingSource::new(3, 1)).await;
1154 }
1155
1156 #[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 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 #[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 #[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 #[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 #[tokio::test]
1253 async fn check11_passes_for_a_source_with_a_fail_probe() {
1254 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 #[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", "opts": { "b": 9, "c": 3 }, "keys": [7], }),
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 #[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 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 Box::new(FailingSource) as Box<dyn Source>
1356 })
1357 .await;
1358 }
1359
1360 #[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 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}