use faucet_core::Source;
use faucet_core::check::{CheckContext, ProbeStatus};
use faucet_source_mongodb_cdc::{MongoCdcSource, MongoCdcSourceConfig};
use futures::StreamExt;
use mongodb::Client;
use mongodb::bson::{Document, doc};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::time::Duration;
use testcontainers::ContainerAsync;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::mongo::Mongo;
const DB: &str = "app";
const COLL: &str = "users";
async fn start_repl_set() -> (ContainerAsync<Mongo>, String) {
let container = Mongo::repl_set()
.start()
.await
.expect("mongo replica-set container start");
let port = container
.get_host_port_ipv4(27017)
.await
.expect("mongo port");
let uri = format!("mongodb://127.0.0.1:{port}/?directConnection=true");
(container, uri)
}
fn config(uri: &str) -> MongoCdcSourceConfig {
serde_json::from_value(json!({
"connection_uri": uri,
"scope": { "type": "collection", "database": DB, "collection": COLL },
"full_document": "update_lookup",
"start_from": { "type": "now" },
"idle_timeout": 5,
"max_await_time_ms": 500,
"batch_size": 0
}))
.expect("config")
}
async fn drain(source: &MongoCdcSource) -> (Vec<Value>, Option<Value>) {
let ctx: HashMap<String, Value> = HashMap::new();
let mut pages = source.stream_pages(&ctx, 0);
let mut records = Vec::new();
let mut bookmark = None;
while let Some(page) = pages.next().await {
let page = page.expect("page");
records.extend(page.records);
if page.bookmark.is_some() {
bookmark = page.bookmark;
}
}
(records, bookmark)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cdc_captures_crud_then_resumes_without_replay() {
let (_container, uri) = start_repl_set().await;
{
let client = Client::with_uri_str(&uri).await.expect("seed client");
client
.database(DB)
.collection::<Document>(COLL)
.insert_one(doc! { "_id": 0, "seed": true })
.await
.expect("seed insert");
}
let source = MongoCdcSource::new(config(&uri)).await.expect("source");
let writer_uri = uri.clone();
let writer = tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let client = Client::with_uri_str(&writer_uri)
.await
.expect("writer client");
let coll = client.database(DB).collection::<Document>(COLL);
coll.insert_one(doc! { "_id": 1, "name": "alice" })
.await
.expect("insert");
coll.update_one(doc! { "_id": 1 }, doc! { "$set": { "name": "bob" } })
.await
.expect("update");
coll.delete_one(doc! { "_id": 1 }).await.expect("delete");
});
let (records, bookmark) = drain(&source).await;
writer.await.expect("writer task");
let ops: Vec<&str> = records
.iter()
.map(|r| r["op"].as_str().unwrap_or(""))
.collect();
assert!(ops.contains(&"c"), "expected a create op, got {ops:?}");
assert!(ops.contains(&"u"), "expected an update op, got {ops:?}");
assert!(ops.contains(&"d"), "expected a delete op, got {ops:?}");
let create = records
.iter()
.find(|r| r["op"] == "c")
.expect("create record");
assert_eq!(create["namespace"]["db"], DB);
assert_eq!(create["namespace"]["coll"], COLL);
assert_eq!(create["after"]["name"], "alice");
assert!(create["resume_token"]["_data"].is_string());
let bookmark = bookmark.expect("cycle 1 produced a bookmark");
source
.apply_start_bookmark(bookmark)
.await
.expect("apply bookmark");
let writer_uri = uri.clone();
let writer2 = tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let client = Client::with_uri_str(&writer_uri)
.await
.expect("writer2 client");
client
.database(DB)
.collection::<Document>(COLL)
.insert_one(doc! { "_id": 2, "name": "carol" })
.await
.expect("insert2");
});
let (records2, _bm2) = drain(&source).await;
writer2.await.expect("writer2 task");
assert!(
!records2.is_empty(),
"expected the post-bookmark insert to be delivered"
);
for r in &records2 {
let key = &r["document_key"]["_id"];
assert_eq!(
key,
&json!(2),
"resume replayed a pre-bookmark event: {r:?}"
);
}
assert!(records2.iter().any(|r| r["op"] == "c"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn instance_metadata_methods() {
let (_container, uri) = start_repl_set().await;
let source = MongoCdcSource::new(config(&uri)).await.expect("source");
assert_eq!(
source.state_key().as_deref(),
Some("mongodb-cdc:coll:app.users"),
"collection-scope state key"
);
assert!(source.supports_exactly_once());
assert_eq!(source.connector_name(), "mongodb-cdc");
let ds = source.dataset_uri();
assert!(
ds.ends_with(&format!("/{DB}/{COLL}")),
"collection dataset_uri: {ds}"
);
let schema = source.config_schema();
let props = schema
.get("properties")
.and_then(|p| p.as_object())
.expect("config_schema must be a JSON object schema");
assert!(props.contains_key("connection_uri"), "schema: {schema}");
assert!(props.contains_key("scope"));
assert!(props.contains_key("start_from"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dataset_uri_database_and_cluster_scopes() {
let (_container, uri) = start_repl_set().await;
let base = faucet_core::redact_uri_credentials(&uri);
let db_cfg: MongoCdcSourceConfig = serde_json::from_value(json!({
"connection_uri": &uri,
"scope": { "type": "database", "database": DB },
"idle_timeout": 5,
"max_await_time_ms": 500,
"batch_size": 0
}))
.expect("db config");
let db_source = MongoCdcSource::new(db_cfg).await.expect("db source");
assert_eq!(db_source.dataset_uri(), format!("{base}/{DB}"));
assert_eq!(
db_source.state_key().as_deref(),
Some(format!("mongodb-cdc:db:{DB}").as_str())
);
let cluster_cfg: MongoCdcSourceConfig = serde_json::from_value(json!({
"connection_uri": &uri,
"scope": { "type": "cluster" },
"idle_timeout": 5,
"max_await_time_ms": 500,
"batch_size": 0
}))
.expect("cluster config");
let cluster_source = MongoCdcSource::new(cluster_cfg)
.await
.expect("cluster source");
assert_eq!(cluster_source.dataset_uri(), base);
assert_eq!(
cluster_source.state_key().as_deref(),
Some("mongodb-cdc:cluster")
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn check_passes_on_replica_set() {
let (_container, uri) = start_repl_set().await;
let source = MongoCdcSource::new(config(&uri)).await.expect("source");
let report = source
.check(&CheckContext::default())
.await
.expect("check report");
let names: Vec<&str> = report.probes.iter().map(|p| p.name).collect();
assert!(names.contains(&"connection"), "probes: {names:?}");
assert!(names.contains(&"topology"), "probes: {names:?}");
for p in &report.probes {
assert!(
matches!(p.status, ProbeStatus::Pass),
"probe {} must pass on a replica set, got {:?}",
p.name,
p.status
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fetch_with_context_drains_into_flat_vec() {
let (_container, uri) = start_repl_set().await;
{
let client = Client::with_uri_str(&uri).await.expect("seed client");
client
.database(DB)
.collection::<Document>(COLL)
.insert_one(doc! { "_id": 0, "seed": true })
.await
.expect("seed insert");
}
let source = MongoCdcSource::new(config(&uri)).await.expect("source");
let writer_uri = uri.clone();
let writer = tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let client = Client::with_uri_str(&writer_uri)
.await
.expect("writer client");
client
.database(DB)
.collection::<Document>(COLL)
.insert_one(doc! { "_id": 7, "name": "eve" })
.await
.expect("insert");
});
let ctx: HashMap<String, Value> = HashMap::new();
let records = source.fetch_with_context(&ctx).await.expect("fetch");
writer.await.expect("writer task");
assert!(
records
.iter()
.any(|r| r["op"] == "c" && r["after"]["name"] == "eve"),
"expected the eve insert in the flattened result, got {records:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn new_warns_but_succeeds_with_pre_image_request() {
let (_container, uri) = start_repl_set().await;
let cfg: MongoCdcSourceConfig = serde_json::from_value(json!({
"connection_uri": uri,
"scope": { "type": "collection", "database": DB, "collection": COLL },
"full_document_before_change": "when_available",
"idle_timeout": 5,
"max_await_time_ms": 500,
"batch_size": 0
}))
.expect("config");
let source = MongoCdcSource::new(cfg).await.expect("source new");
assert_eq!(source.connector_name(), "mongodb-cdc");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn new_rejects_standalone_topology() {
let container = Mongo::default()
.start()
.await
.expect("standalone mongo start");
let port = container.get_host_port_ipv4(27017).await.expect("port");
let uri = format!("mongodb://127.0.0.1:{port}/?directConnection=true");
let cfg: MongoCdcSourceConfig = serde_json::from_value(json!({
"connection_uri": uri,
"scope": { "type": "collection", "database": DB, "collection": COLL },
"idle_timeout": 5,
"max_await_time_ms": 500,
"batch_size": 0
}))
.expect("config");
match MongoCdcSource::new(cfg).await {
Err(faucet_core::FaucetError::Source(m)) => {
assert!(
m.contains("replica set") || m.contains("standalone"),
"expected a topology rejection, got: {m}"
);
}
Err(other) => panic!("expected a Source topology error, got {other:?}"),
Ok(_) => panic!("standalone mongod must be rejected for change streams"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn capture_resume_position_returns_resume_token() {
let (_container, uri) = start_repl_set().await;
let source = MongoCdcSource::new(config(&uri)).await.expect("source");
let pos = source
.capture_resume_position()
.await
.expect("capture")
.expect("mongodb-cdc must support capture");
assert!(
pos.get("resume_token").is_some(),
"resume_token present: {pos}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn capture_resume_position_database_scope() {
let (_container, uri) = start_repl_set().await;
let cfg: MongoCdcSourceConfig = serde_json::from_value(json!({
"connection_uri": &uri,
"scope": { "type": "database", "database": DB },
"start_from": { "type": "now" },
"idle_timeout": 5,
"max_await_time_ms": 500,
"batch_size": 0
}))
.expect("database-scope config");
let source = MongoCdcSource::new(cfg).await.expect("source");
let pos = source
.capture_resume_position()
.await
.expect("capture")
.expect("mongodb-cdc must support capture");
assert!(
pos.get("resume_token").is_some(),
"resume_token present: {pos}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn capture_resume_position_cluster_scope() {
let (_container, uri) = start_repl_set().await;
let cfg: MongoCdcSourceConfig = serde_json::from_value(json!({
"connection_uri": &uri,
"scope": { "type": "cluster" },
"start_from": { "type": "now" },
"idle_timeout": 5,
"max_await_time_ms": 500,
"batch_size": 0
}))
.expect("cluster-scope config");
let source = MongoCdcSource::new(cfg).await.expect("source");
let pos = source
.capture_resume_position()
.await
.expect("capture")
.expect("mongodb-cdc must support capture");
assert!(
pos.get("resume_token").is_some(),
"resume_token present: {pos}"
);
}