#![cfg_attr(docsrs, feature(doc_cfg))]
pub mod doubles;
use std::collections::HashMap;
use faucet_core::{Sink, Source, Value};
use futures::StreamExt;
pub trait HasConfigSchema {
fn conformance_schema(&self) -> Value;
fn conformance_label(&self) -> String;
}
impl<T: Source + ?Sized> HasConfigSchema for T {
fn conformance_schema(&self) -> Value {
self.config_schema()
}
fn conformance_label(&self) -> String {
self.connector_name().to_string()
}
}
pub fn assert_config_schema_valid<C: HasConfigSchema + ?Sized>(connector: &C) {
assert_config_schema_valid_value(
&connector.conformance_schema(),
&connector.conformance_label(),
);
}
pub fn assert_config_schema_valid_value(schema: &Value, label: &str) {
let obj = schema.as_object().unwrap_or_else(|| {
panic!("[{label}] config_schema() must be a JSON object, got: {schema}")
});
let recognized = [
"type",
"properties",
"$ref",
"oneOf",
"allOf",
"anyOf",
"$schema",
"enum",
]
.iter()
.any(|k| obj.contains_key(*k));
assert!(
recognized,
"[{label}] config_schema() has no recognizable JSON Schema keyword: {schema}"
);
if let Some(props) = obj.get("properties") {
assert!(
props.is_object(),
"[{label}] config_schema().properties must be an object, got: {props}"
);
}
if let Some(ty) = obj.get("type") {
assert!(
ty.is_string() || ty.is_array(),
"[{label}] config_schema().type must be a string or array, got: {ty}"
);
}
let text = serde_json::to_string(schema).expect("schema serializes");
let reparsed: Value = serde_json::from_str(&text).expect("schema re-parses");
assert_eq!(
&reparsed, schema,
"[{label}] config_schema() does not round-trip through serde_json"
);
}
pub async fn assert_bounded_memory<S: Source + ?Sized>(
source: &S,
batch_size: usize,
total: usize,
) {
assert!(
batch_size > 0,
"batch_size must be > 0 for a bounded-memory check"
);
assert!(
total > batch_size,
"total ({total}) must exceed batch_size ({batch_size}) for a meaningful check"
);
let label = source.connector_name();
let ctx: HashMap<String, Value> = HashMap::new();
let mut stream = source.stream_pages(&ctx, batch_size);
let mut seen = 0usize;
let mut peak = 0usize;
while let Some(page) = stream.next().await {
let page = page.unwrap_or_else(|e| panic!("[{label}] stream_pages errored: {e}"));
peak = peak.max(page.records.len());
seen += page.records.len();
}
assert_eq!(
seen, total,
"[{label}] streamed {seen} records, expected {total}"
);
assert!(
peak <= batch_size,
"[{label}] peak page {peak} exceeds batch_size {batch_size} (not bounded)"
);
assert!(
peak < total,
"[{label}] peak page {peak} == total: source buffered the whole set into one page"
);
}
pub async fn assert_bookmark_roundtrip<S: Source + ?Sized>(source: &S) {
let label = source.connector_name();
let ctx: HashMap<String, Value> = HashMap::new();
let (first_records, bookmark) = drain(source, &ctx, label).await;
assert!(
first_records > 0,
"[{label}] produced no records — cannot exercise bookmark round-trip"
);
let bookmark = bookmark.unwrap_or_else(|| {
panic!("[{label}] produced no bookmark to round-trip (stream_pages never set one)")
});
source
.apply_start_bookmark(bookmark.clone())
.await
.unwrap_or_else(|e| panic!("[{label}] apply_start_bookmark errored: {e}"));
let (second_records, _) = drain(source, &ctx, label).await;
assert!(
second_records < first_records,
"[{label}] resumed run replayed {second_records} records (first run: {first_records}); \
the bookmark {bookmark} was ignored — no incremental resume"
);
}
async fn drain<S: Source + ?Sized>(
source: &S,
ctx: &HashMap<String, Value>,
label: &str,
) -> (usize, Option<Value>) {
let mut stream = source.stream_pages(ctx, 100);
let mut count = 0usize;
let mut last_bookmark = None;
while let Some(page) = stream.next().await {
let page = page.unwrap_or_else(|e| panic!("[{label}] stream_pages errored: {e}"));
count += page.records.len();
if page.bookmark.is_some() {
last_bookmark = page.bookmark;
}
}
(count, last_bookmark)
}
pub async fn assert_idempotent_replay<S, F, Fut>(sink: &S, distinct_count: F)
where
S: Sink + ?Sized,
F: Fn() -> Fut,
Fut: std::future::Future<Output = usize>,
{
let label = sink.connector_name();
if sink.supports_idempotent_writes() {
assert_watermark_idempotent(sink, &distinct_count, label).await;
} else if sink.dedups_by_key() {
assert_keyed_convergence(sink, &distinct_count, label).await;
} else {
panic!(
"[{label}] advertises no idempotency mechanism \
(supports_idempotent_writes=false, dedups_by_key=false) — nothing to verify"
);
}
}
fn rows(ids: &[i64]) -> Vec<Value> {
ids.iter()
.map(|i| serde_json::json!({ "id": i, "v": format!("v{i}") }))
.collect()
}
async fn assert_watermark_idempotent<S, F, Fut>(sink: &S, count: &F, label: &str)
where
S: Sink + ?Sized,
F: Fn() -> Fut,
Fut: std::future::Future<Output = usize>,
{
let scope = "conformance::idem";
let before = count().await;
let t1 = faucet_core::format_token(1);
let p1 = rows(&[1, 2, 3]);
sink.write_batch_idempotent(&p1, scope, &t1)
.await
.unwrap_or_else(|e| panic!("[{label}] write_batch_idempotent(page 1) errored: {e}"));
let after_first = count().await;
assert_eq!(
after_first - before,
3,
"[{label}] first idempotent write did not add all 3 rows"
);
let committed = sink
.last_committed_token(scope)
.await
.unwrap_or_else(|e| panic!("[{label}] last_committed_token errored: {e}"));
assert_eq!(
committed.as_deref(),
Some(t1.as_str()),
"[{label}] did not durably record its commit token — cannot skip a replay"
);
if faucet_core::parse_token(committed.as_deref().unwrap_or_default())
.is_some_and(|c| c >= faucet_core::parse_token(&t1).unwrap_or(0))
{
} else {
panic!("[{label}] committed token did not advance to the written page's token");
}
let after_replay = count().await;
assert_eq!(
after_replay, after_first,
"[{label}] a guarded replay changed the destination — watermark is not honoured"
);
let t2 = faucet_core::format_token(2);
let p2 = rows(&[4, 5]);
sink.write_batch_idempotent(&p2, scope, &t2)
.await
.unwrap_or_else(|e| panic!("[{label}] write_batch_idempotent(page 2) errored: {e}"));
let after_second = count().await;
assert_eq!(
after_second - after_first,
2,
"[{label}] forward progress after a new token did not add the new rows"
);
}
async fn assert_keyed_convergence<S, F, Fut>(sink: &S, count: &F, label: &str)
where
S: Sink + ?Sized,
F: Fn() -> Fut,
Fut: std::future::Future<Output = usize>,
{
let before = count().await;
sink.write_batch(&rows(&[1, 2, 3]))
.await
.unwrap_or_else(|e| panic!("[{label}] write_batch(page 1) errored: {e}"));
sink.write_batch(&rows(&[2, 3, 4]))
.await
.unwrap_or_else(|e| panic!("[{label}] write_batch(overlapping page) errored: {e}"));
let after = count().await;
assert_eq!(
after - before,
4,
"[{label}] overlapping keys did not converge: expected 4 distinct rows (ids 1-4), \
got {}",
after - before
);
}
pub async fn assert_capabilities_truthful<S, F, Fut>(sink: &S, distinct_count: F)
where
S: Sink + ?Sized,
F: Fn() -> Fut,
Fut: std::future::Future<Output = usize>,
{
let label = sink.connector_name();
assert!(
sink.supported_write_modes()
.contains(&faucet_core::write_mode::WriteMode::Append),
"[{label}] does not advertise Append — every sink must support append"
);
if sink.supports_idempotent_writes() || sink.dedups_by_key() {
assert_idempotent_replay(sink, &distinct_count).await;
} else {
let before = distinct_count().await;
sink.write_batch(&rows(&[100]))
.await
.unwrap_or_else(|e| panic!("[{label}] write_batch (append probe) errored: {e}"));
assert_eq!(
distinct_count().await - before,
1,
"[{label}] Append is advertised but write_batch did not add a row"
);
assert_eq!(
sink.last_committed_token("conformance::honest")
.await
.unwrap_or_else(|e| panic!("[{label}] last_committed_token errored: {e}")),
None,
"[{label}] is not idempotent yet reports a committed token"
);
}
if sink.supports_schema_evolution() {
let empty = faucet_core::drift::SchemaEvolution::default();
sink.evolve_schema(&empty).await.unwrap_or_else(|e| {
panic!("[{label}] advertises schema evolution but evolve_schema(no-op) errored: {e}")
});
}
}
pub async fn assert_errors_not_panics<S: Source + ?Sized>(source: &S) {
use futures::FutureExt;
let label = source.connector_name();
let outcome = std::panic::AssertUnwindSafe(source.fetch_all())
.catch_unwind()
.await;
match outcome {
Err(_) => panic!("[{label}] panicked instead of returning Err from fetch_all"),
Ok(Ok(_)) => panic!("[{label}] expected a failure but fetch_all succeeded"),
Ok(Err(_e)) => { }
}
let ctx: HashMap<String, Value> = HashMap::new();
let stream_outcome = std::panic::AssertUnwindSafe(async {
let mut s = source.stream_pages(&ctx, 100);
s.next().await
})
.catch_unwind()
.await;
match stream_outcome {
Err(_) => panic!("[{label}] panicked instead of returning Err from stream_pages"),
Ok(Some(Err(_e))) => { }
Ok(None) => panic!("[{label}] stream_pages yielded no pages (expected an error)"),
Ok(Some(Ok(_))) => {
panic!("[{label}] expected a failure but stream_pages produced a page")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use doubles::{
CountingSource, FailingSource, LyingIdempotentSink, LyingKeyedSink, PanickingSource,
TestSink,
};
#[test]
fn check1_accepts_a_valid_source_schema() {
let s = CountingSource::new(10, 2);
assert_config_schema_valid(&s);
}
#[test]
fn check1_value_form_works_for_a_sink() {
let sink = TestSink::new();
assert_config_schema_valid_value(&sink.config_schema(), sink.connector_name());
}
#[test]
#[should_panic(expected = "no recognizable JSON Schema keyword")]
fn check1_rejects_a_non_schema() {
assert_config_schema_valid_value(&serde_json::json!({"nope": 1}), "bogus");
}
#[tokio::test]
async fn check2_passes_for_a_paging_source() {
let s = CountingSource::new(1000, 100);
assert_bounded_memory(&s, 100, 1000).await;
}
#[tokio::test]
#[should_panic(expected = "not bounded")]
async fn check2_fails_when_source_emits_one_big_page() {
let s = CountingSource::new(500, 0);
assert_bounded_memory(&s, 100, 500).await;
}
#[tokio::test]
async fn check3_passes_for_a_resumable_source() {
let s = CountingSource::new(500, 100);
assert_bookmark_roundtrip(&s).await;
}
#[tokio::test]
#[should_panic(expected = "was ignored")]
async fn check3_fails_when_source_ignores_the_bookmark() {
let s = CountingSource::non_resumable(500, 100);
assert_bookmark_roundtrip(&s).await;
}
#[tokio::test]
async fn check4_passes_for_a_watermark_sink() {
let sink = TestSink::idempotent("id");
let s = sink.clone();
assert_idempotent_replay(&sink, || {
let s = s.clone();
async move { s.len() }
})
.await;
}
#[tokio::test]
async fn check4_passes_for_a_keyed_upsert_sink() {
let sink = TestSink::keyed("id");
let s = sink.clone();
assert_idempotent_replay(&sink, || {
let s = s.clone();
async move { s.len() }
})
.await;
}
#[tokio::test]
#[should_panic(expected = "did not durably record its commit token")]
async fn check4_fails_for_a_lying_idempotent_sink() {
let sink = LyingIdempotentSink::new();
let s = sink.clone();
assert_idempotent_replay(&sink, || {
let s = s.clone();
async move { s.len() }
})
.await;
}
#[tokio::test]
#[should_panic(expected = "did not converge")]
async fn check4_fails_for_a_lying_keyed_sink() {
let sink = LyingKeyedSink::new();
let s = sink.clone();
assert_idempotent_replay(&sink, || {
let s = s.clone();
async move { s.len() }
})
.await;
}
#[tokio::test]
#[should_panic(expected = "no idempotency mechanism")]
async fn check4_fails_for_an_append_only_sink() {
let sink = TestSink::new();
let s = sink.clone();
assert_idempotent_replay(&sink, || {
let s = s.clone();
async move { s.len() }
})
.await;
}
#[tokio::test]
async fn check5_passes_for_an_honest_append_sink() {
let sink = TestSink::new();
let s = sink.clone();
assert_capabilities_truthful(&sink, || {
let s = s.clone();
async move { s.len() }
})
.await;
}
#[tokio::test]
async fn check5_passes_for_an_honest_idempotent_sink() {
let sink = TestSink::idempotent("id");
let s = sink.clone();
assert_capabilities_truthful(&sink, || {
let s = s.clone();
async move { s.len() }
})
.await;
}
#[tokio::test]
#[should_panic(expected = "did not durably record its commit token")]
async fn check5_fails_for_a_lying_idempotent_sink() {
let sink = LyingIdempotentSink::new();
let s = sink.clone();
assert_capabilities_truthful(&sink, || {
let s = s.clone();
async move { s.len() }
})
.await;
}
#[tokio::test]
async fn check6_passes_for_a_source_that_returns_err() {
assert_errors_not_panics(&FailingSource).await;
}
#[tokio::test]
#[should_panic(expected = "panicked instead of returning Err")]
async fn check6_fails_for_a_source_that_panics() {
assert_errors_not_panics(&PanickingSource).await;
}
#[tokio::test]
#[should_panic(expected = "expected a failure but fetch_all succeeded")]
async fn check6_fails_for_a_source_that_succeeds() {
assert_errors_not_panics(&CountingSource::new(3, 1)).await;
}
}