#![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 _ = source;
}
pub async fn assert_idempotent_replay<S: Sink + ?Sized>(sink: &S) {
let _ = sink;
}
pub fn assert_capabilities_truthful<S: Sink + ?Sized>(sink: &S) {
let _ = sink;
}
pub async fn assert_errors_not_panics<S: Source + ?Sized>(source: &S) {
let _ = source;
}
#[cfg(test)]
mod tests {
use super::*;
use doubles::{CountingSource, 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;
}
}