1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3pub mod doubles;
37
38use std::collections::HashMap;
39
40use faucet_core::{Sink, Source, Value};
41use futures::StreamExt;
42
43pub trait HasConfigSchema {
49 fn conformance_schema(&self) -> Value;
51 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
64pub 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
77pub 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 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 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
124pub 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 }
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
174pub 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 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 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
214async 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
233pub 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
270fn 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 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 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 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 } 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 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 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
367pub 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 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 assert_idempotent_replay(sink, &distinct_count).await;
396 } else {
397 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 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
426pub async fn assert_errors_not_panics<S: Source + ?Sized>(source: &S) {
437 use futures::FutureExt;
438 let label = source.connector_name();
439
440 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)) => { }
448 }
449
450 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))) => { }
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 let s = CountingSource::new(500, 0);
505 assert_bounded_memory(&s, 100, 500).await;
506 }
507
508 #[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 #[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 #[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 #[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 assert_errors_not_panics(&CountingSource::new(3, 1)).await;
638 }
639}