1use crate::config::{MongoCdcSourceConfig, StartFrom};
4use crate::envelope::to_envelope;
5use crate::state::{Bookmark, state_key};
6use async_trait::async_trait;
7use faucet_core::{FaucetError, Source, Stream, StreamPage};
8use mongodb::Client;
9use mongodb::bson::{self, Document, Timestamp};
10use mongodb::change_stream::event::ResumeToken;
11use mongodb::options::{FullDocumentBeforeChangeType, FullDocumentType};
12use serde_json::Value;
13use std::collections::HashMap;
14use std::pin::Pin;
15use tokio::sync::Mutex;
16
17#[derive(Clone, Debug, PartialEq)]
19pub(crate) enum StartPosition {
20 Now,
22 AtOperationTime(Timestamp),
24 ResumeAfter(ResumeToken),
26}
27
28pub(crate) fn resolve_start(
33 start_from: &StartFrom,
34 pending: Option<&Bookmark>,
35) -> Result<StartPosition, FaucetError> {
36 match start_from {
37 StartFrom::ResumeToken { token } => {
38 let b = Bookmark {
39 resume_token: token.clone(),
40 };
41 Ok(StartPosition::ResumeAfter(b.to_token()?))
42 }
43 StartFrom::Timestamp { timestamp_secs } => Ok(StartPosition::AtOperationTime(Timestamp {
44 time: *timestamp_secs,
45 increment: 0,
46 })),
47 StartFrom::Now | StartFrom::Earliest => {
48 if let Some(b) = pending {
49 Ok(StartPosition::ResumeAfter(b.to_token()?))
50 } else if matches!(start_from, StartFrom::Earliest) {
51 Ok(StartPosition::AtOperationTime(Timestamp {
53 time: 1,
54 increment: 1,
55 }))
56 } else {
57 Ok(StartPosition::Now)
58 }
59 }
60 }
61}
62
63pub(crate) fn check_staging_cap(
72 current_len: usize,
73 max_staged: Option<usize>,
74) -> Result<(), FaucetError> {
75 if let Some(max) = max_staged
76 && current_len >= max
77 {
78 return Err(FaucetError::Source(format!(
79 "mongodb-cdc: in-memory change buffer exceeded max_staged_records ({max}); \
80 aborting to avoid unbounded memory growth. Raise max_staged_records or \
81 reduce batch_size so pages flush more often."
82 )));
83 }
84 Ok(())
85}
86
87pub struct MongoCdcSource {
89 config: MongoCdcSourceConfig,
90 client: Client,
91 state_key_value: String,
92 pending_bookmark: Mutex<Option<Bookmark>>,
93}
94
95impl MongoCdcSource {
96 pub async fn new(config: MongoCdcSourceConfig) -> Result<Self, FaucetError> {
98 config.validate()?;
99 let client = Client::with_uri_str(&config.connection_uri)
100 .await
101 .map_err(|e| FaucetError::Source(format!("MongoDB connection failed: {e}")))?;
102 ensure_changestream_capable(&client).await?;
103 if config.full_document_before_change != crate::config::FullDocumentBeforeChange::Off {
104 tracing::warn!(
105 "mongodb-cdc: full_document_before_change requires MongoDB 6.0+ and the \
106 collection's changeStreamPreAndPostImages enabled; the server will error \
107 at stream open if unavailable"
108 );
109 }
110 let state_key_value = state_key(&config.scope);
111 Ok(Self {
112 config,
113 client,
114 state_key_value,
115 pending_bookmark: Mutex::new(None),
116 })
117 }
118}
119
120async fn ensure_changestream_capable(client: &Client) -> Result<(), FaucetError> {
122 let hello = client
123 .database("admin")
124 .run_command(bson::doc! { "hello": 1 })
125 .await
126 .map_err(|e| FaucetError::Source(format!("mongodb-cdc hello failed: {e}")))?;
127 if is_changestream_topology(&hello) {
128 Ok(())
129 } else {
130 Err(FaucetError::Source(
131 "mongodb-cdc: Change Streams require a replica set or sharded cluster; \
132 the target appears to be a standalone mongod"
133 .into(),
134 ))
135 }
136}
137
138pub(crate) fn is_changestream_topology(hello: &Document) -> bool {
140 hello.get_str("setName").is_ok()
141 || hello
142 .get_str("msg")
143 .map(|m| m == "isdbgrid")
144 .unwrap_or(false)
145}
146
147pub(crate) fn full_document_type(fd: crate::config::FullDocument) -> Option<FullDocumentType> {
149 use crate::config::FullDocument as F;
150 match fd {
151 F::Off => None,
152 F::WhenAvailable => Some(FullDocumentType::WhenAvailable),
153 F::Required => Some(FullDocumentType::Required),
154 F::UpdateLookup => Some(FullDocumentType::UpdateLookup),
155 }
156}
157
158pub(crate) fn full_document_before_type(
160 fd: crate::config::FullDocumentBeforeChange,
161) -> Option<FullDocumentBeforeChangeType> {
162 use crate::config::FullDocumentBeforeChange as F;
163 match fd {
164 F::Off => None,
165 F::WhenAvailable => Some(FullDocumentBeforeChangeType::WhenAvailable),
166 F::Required => Some(FullDocumentBeforeChangeType::Required),
167 }
168}
169
170pub(crate) fn build_pipeline(config: &MongoCdcSourceConfig) -> Result<Vec<Document>, FaucetError> {
172 let mut pipeline: Vec<Document> = Vec::new();
173 if !config.operation_types.is_empty() {
174 pipeline.push(bson::doc! {
175 "$match": { "operationType": { "$in": config.operation_types.clone() } }
176 });
177 }
178 for (i, stage) in config.aggregation_pipeline.iter().enumerate() {
179 let b = bson::to_bson(stage)
180 .map_err(|e| FaucetError::Config(format!("mongodb-cdc: pipeline stage {i}: {e}")))?;
181 match b {
182 bson::Bson::Document(d) => pipeline.push(d),
183 other => {
184 return Err(FaucetError::Config(format!(
185 "mongodb-cdc: aggregation_pipeline[{i}] must be an object, got {other:?}"
186 )));
187 }
188 }
189 }
190 Ok(pipeline)
191}
192
193#[async_trait]
194impl Source for MongoCdcSource {
195 async fn fetch_with_context(
196 &self,
197 ctx: &HashMap<String, Value>,
198 ) -> Result<Vec<Value>, FaucetError> {
199 use futures::StreamExt;
200 let mut pages = self.stream_pages(ctx, 0);
201 let mut all = Vec::new();
202 while let Some(page) = pages.next().await {
203 all.extend(page?.records);
204 }
205 Ok(all)
206 }
207
208 fn stream_pages<'a>(
209 &'a self,
210 ctx: &'a HashMap<String, Value>,
211 _batch_size: usize,
212 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
213 self.stream_pages_impl(ctx, self.config.batch_size)
214 }
215
216 fn config_schema(&self) -> Value {
217 serde_json::to_value(schemars::schema_for!(MongoCdcSourceConfig)).unwrap_or(Value::Null)
218 }
219
220 fn state_key(&self) -> Option<String> {
221 Some(self.state_key_value.clone())
222 }
223
224 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
225 let b = Bookmark::from_value(bookmark)?;
226 b.to_token()?;
228 *self.pending_bookmark.lock().await = Some(b);
229 Ok(())
230 }
231
232 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
233 let token = self.capture_now_token().await?;
234 Ok(Some(Bookmark::from_token(&token)?.to_value()?))
235 }
236
237 fn supports_exactly_once(&self) -> bool {
238 true
241 }
242
243 fn connector_name(&self) -> &'static str {
244 "mongodb-cdc"
245 }
246
247 fn dataset_uri(&self) -> String {
248 use crate::config::Scope;
249 let base = faucet_core::redact_uri_credentials(&self.config.connection_uri);
250 match &self.config.scope {
251 Scope::Collection {
252 database,
253 collection,
254 } => {
255 format!("{base}/{database}/{collection}")
256 }
257 Scope::Database { database } => format!("{base}/{database}"),
258 Scope::Cluster => base,
259 }
260 }
261
262 async fn check(
263 &self,
264 ctx: &faucet_core::check::CheckContext,
265 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
266 use faucet_core::check::{CheckReport, Probe};
267 let start = std::time::Instant::now();
268
269 let hello = tokio::time::timeout(
275 ctx.timeout,
276 self.client
277 .database("admin")
278 .run_command(bson::doc! { "hello": 1 }),
279 )
280 .await;
281
282 let hello = match hello {
283 Ok(Ok(doc)) => doc,
284 Ok(Err(e)) => {
285 return Ok(CheckReport::single(Probe::fail_hint(
286 "connection",
287 start.elapsed(),
288 format!("could not run hello: {e}"),
289 "verify the URI is reachable and credentials are valid",
290 )));
291 }
292 Err(_elapsed) => {
293 return Ok(CheckReport::single(Probe::fail_hint(
294 "connection",
295 start.elapsed(),
296 "connection timed out",
297 "the server did not respond within the check timeout",
298 )));
299 }
300 };
301
302 let connection = Probe::pass("connection", start.elapsed());
303 let topology = if is_changestream_topology(&hello) {
304 Probe::pass("topology", start.elapsed())
305 } else {
306 Probe::fail_hint(
307 "topology",
308 start.elapsed(),
309 "target is a standalone mongod",
310 "Change Streams require a replica set or sharded cluster",
311 )
312 };
313 Ok(CheckReport {
314 probes: vec![connection, topology],
315 })
316 }
317}
318
319impl MongoCdcSource {
320 fn stream_pages_impl<'a>(
322 &'a self,
323 _ctx: &'a HashMap<String, Value>,
324 batch_size: usize,
325 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
326 let idle_timeout = self.config.idle_timeout;
327 let max_await = std::time::Duration::from_millis(self.config.max_await_time_ms);
328 let per_batch = batch_size != 0;
329 let max_staged = self.config.max_staged_records;
330
331 Box::pin(async_stream::try_stream! {
332 use futures::StreamExt;
333
334 let pending = self.pending_bookmark.lock().await.take();
335 let start = resolve_start(&self.config.start_from, pending.as_ref())?;
336 let pipeline = build_pipeline(&self.config)?;
337
338 let change_stream: mongodb::change_stream::ChangeStream<
341 mongodb::change_stream::event::ChangeStreamEvent<Document>,
342 > = match &self.config.scope {
343 crate::config::Scope::Collection { database, collection } => {
344 let coll: mongodb::Collection<Document> =
345 self.client.database(database).collection(collection);
346 let mut w = coll
347 .watch()
348 .pipeline(pipeline)
349 .max_await_time(max_await);
350 if let Some(fd) = full_document_type(self.config.full_document) {
351 w = w.full_document(fd);
352 }
353 if let Some(fb) =
354 full_document_before_type(self.config.full_document_before_change)
355 {
356 w = w.full_document_before_change(fb);
357 }
358 w = match &start {
359 StartPosition::Now => w,
360 StartPosition::AtOperationTime(ts) => w.start_at_operation_time(*ts),
361 StartPosition::ResumeAfter(tok) => w.resume_after(tok.clone()),
362 };
363 w.await
364 .map_err(|e| FaucetError::Source(format!("mongodb-cdc watch failed: {e}")))?
365 }
366 crate::config::Scope::Database { database } => {
367 let db = self.client.database(database);
368 let mut w = db
369 .watch()
370 .pipeline(pipeline)
371 .max_await_time(max_await);
372 if let Some(fd) = full_document_type(self.config.full_document) {
373 w = w.full_document(fd);
374 }
375 if let Some(fb) =
376 full_document_before_type(self.config.full_document_before_change)
377 {
378 w = w.full_document_before_change(fb);
379 }
380 w = match &start {
381 StartPosition::Now => w,
382 StartPosition::AtOperationTime(ts) => w.start_at_operation_time(*ts),
383 StartPosition::ResumeAfter(tok) => w.resume_after(tok.clone()),
384 };
385 w.await
386 .map_err(|e| FaucetError::Source(format!("mongodb-cdc watch failed: {e}")))?
387 }
388 crate::config::Scope::Cluster => {
389 let mut w = self.client
390 .watch()
391 .pipeline(pipeline)
392 .max_await_time(max_await);
393 if let Some(fd) = full_document_type(self.config.full_document) {
394 w = w.full_document(fd);
395 }
396 if let Some(fb) =
397 full_document_before_type(self.config.full_document_before_change)
398 {
399 w = w.full_document_before_change(fb);
400 }
401 w = match &start {
402 StartPosition::Now => w,
403 StartPosition::AtOperationTime(ts) => w.start_at_operation_time(*ts),
404 StartPosition::ResumeAfter(tok) => w.resume_after(tok.clone()),
405 };
406 w.await
407 .map_err(|e| FaucetError::Source(format!("mongodb-cdc watch failed: {e}")))?
408 }
409 };
410 let mut change_stream = std::pin::pin!(change_stream);
411
412 let chunk = if per_batch { batch_size } else { usize::MAX };
413 let mut buffer: Vec<Value> = Vec::new();
414 let mut last_bookmark: Option<Value> = None;
417
418 loop {
426 match tokio::time::timeout(idle_timeout, change_stream.next()).await {
427 Ok(Some(Ok(event))) => {
428 let bookmark = Bookmark::from_token(&event.id)?;
429 let is_invalidate = matches!(
430 event.operation_type,
431 mongodb::change_stream::event::OperationType::Invalidate
432 );
433 check_staging_cap(buffer.len(), max_staged)?;
439 buffer.push(to_envelope(&event, &bookmark)?);
440 last_bookmark = Some(bookmark.to_value()?);
441
442 if is_invalidate {
445 yield StreamPage {
446 records: std::mem::take(&mut buffer),
447 bookmark: last_bookmark.take(),
448 };
449 break;
450 }
451 if per_batch && buffer.len() >= chunk {
452 yield StreamPage {
453 records: std::mem::take(&mut buffer),
454 bookmark: last_bookmark.take(),
455 };
456 }
457 }
458 Ok(Some(Err(e))) => {
459 Err(FaucetError::Source(format!("mongodb-cdc stream error: {e}")))?;
460 }
461 Ok(None) | Err(_) => {
464 if !buffer.is_empty() {
465 yield StreamPage {
466 records: std::mem::take(&mut buffer),
467 bookmark: last_bookmark.take(),
468 };
469 }
470 break;
471 }
472 }
473 }
474
475 tracing::info!(connector = "mongodb-cdc", "change stream fetch cycle complete");
476 })
477 }
478
479 async fn capture_now_token(&self) -> Result<ResumeToken, FaucetError> {
483 use crate::config::Scope;
484 use futures::StreamExt;
485 let max_await = std::time::Duration::from_millis(self.config.max_await_time_ms);
486 let cs: mongodb::change_stream::ChangeStream<
487 mongodb::change_stream::event::ChangeStreamEvent<Document>,
488 > = match &self.config.scope {
489 Scope::Collection {
490 database,
491 collection,
492 } => self
493 .client
494 .database(database)
495 .collection::<Document>(collection)
496 .watch()
497 .max_await_time(max_await)
498 .await
499 .map_err(|e| {
500 FaucetError::Source(format!("mongodb-cdc capture watch failed: {e}"))
501 })?,
502 Scope::Database { database } => self
503 .client
504 .database(database)
505 .watch()
506 .max_await_time(max_await)
507 .await
508 .map_err(|e| {
509 FaucetError::Source(format!("mongodb-cdc capture watch failed: {e}"))
510 })?,
511 Scope::Cluster => self
512 .client
513 .watch()
514 .max_await_time(max_await)
515 .await
516 .map_err(|e| {
517 FaucetError::Source(format!("mongodb-cdc capture watch failed: {e}"))
518 })?,
519 };
520 let mut cs = std::pin::pin!(cs);
521 if let Some(tok) = cs.resume_token() {
525 return Ok(tok);
526 }
527 let _ = tokio::time::timeout(
528 max_await.max(std::time::Duration::from_millis(500)),
529 cs.next(),
530 )
531 .await;
532 cs.resume_token().ok_or_else(|| {
533 FaucetError::Source(
534 "mongodb-cdc: could not capture a resume token (no postBatchResumeToken returned)"
535 .into(),
536 )
537 })
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use serde_json::json;
545
546 #[test]
547 fn explicit_timestamp_overrides_bookmark() {
548 let pending = Bookmark {
549 resume_token: json!({ "_data": "AA" }),
550 };
551 let pos = resolve_start(
552 &StartFrom::Timestamp {
553 timestamp_secs: 100,
554 },
555 Some(&pending),
556 )
557 .unwrap();
558 assert_eq!(
559 pos,
560 StartPosition::AtOperationTime(Timestamp {
561 time: 100,
562 increment: 0
563 })
564 );
565 }
566
567 #[test]
568 fn explicit_resume_token_overrides_bookmark() {
569 let pending = Bookmark {
572 resume_token: json!({ "_data": "PENDING" }),
573 };
574 let pos = resolve_start(
575 &StartFrom::ResumeToken {
576 token: json!({ "_data": "8264AB00" }),
577 },
578 Some(&pending),
579 )
580 .unwrap();
581 match pos {
584 StartPosition::ResumeAfter(tok) => {
585 let b = Bookmark::from_token(&tok).unwrap();
586 assert_eq!(b.resume_token["_data"], json!("8264AB00"));
587 }
588 other => panic!("expected ResumeAfter, got {other:?}"),
589 }
590 }
591
592 #[test]
593 fn now_yields_to_bookmark() {
594 let pending = Bookmark {
595 resume_token: json!({ "_data": "8264AB00" }),
596 };
597 let pos = resolve_start(&StartFrom::Now, Some(&pending)).unwrap();
598 assert!(matches!(pos, StartPosition::ResumeAfter(_)));
599 }
600
601 #[test]
602 fn now_without_bookmark_is_now() {
603 assert_eq!(
604 resolve_start(&StartFrom::Now, None).unwrap(),
605 StartPosition::Now
606 );
607 }
608
609 #[test]
610 fn earliest_without_bookmark_uses_operation_time() {
611 assert_eq!(
612 resolve_start(&StartFrom::Earliest, None).unwrap(),
613 StartPosition::AtOperationTime(Timestamp {
614 time: 1,
615 increment: 1
616 })
617 );
618 }
619
620 #[test]
621 fn staging_cap_none_is_unbounded() {
622 assert!(check_staging_cap(0, None).is_ok());
624 assert!(check_staging_cap(1_000_000, None).is_ok());
625 }
626
627 #[test]
628 fn staging_cap_allows_up_to_limit() {
629 assert!(check_staging_cap(0, Some(2)).is_ok());
632 assert!(check_staging_cap(1, Some(2)).is_ok());
633 }
634
635 #[test]
636 fn staging_cap_aborts_at_limit() {
637 let err = check_staging_cap(2, Some(2)).unwrap_err();
640 assert!(matches!(err, FaucetError::Source(_)));
641 assert!(format!("{err}").contains("max_staged_records"));
642
643 assert!(check_staging_cap(3, Some(2)).is_err());
645 }
646
647 #[test]
648 fn staging_cap_drives_accumulation_past_limit() {
649 let mut buffer: Vec<u8> = Vec::new();
652 let max = Some(3usize);
653 let mut last: Result<(), FaucetError> = Ok(());
654 for i in 0..10u8 {
655 if let Err(e) = check_staging_cap(buffer.len(), max) {
656 last = Err(e);
657 break;
658 }
659 buffer.push(i);
660 }
661 assert_eq!(buffer.len(), 3, "buffer stops growing at the cap");
662 let err = last.unwrap_err();
663 assert!(matches!(err, FaucetError::Source(_)));
664 assert!(format!("{err}").contains("max_staged_records"));
665 }
666
667 #[test]
668 fn topology_classification() {
669 assert!(is_changestream_topology(&bson::doc! { "setName": "rs0" }));
670 assert!(is_changestream_topology(&bson::doc! { "msg": "isdbgrid" }));
671 assert!(!is_changestream_topology(&bson::doc! { "ok": 1.0 }));
672 }
673
674 #[test]
675 fn pipeline_prepends_operation_match() {
676 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
677 "connection_uri": "mongodb://h/?replicaSet=rs0",
678 "scope": { "type": "cluster" },
679 "operation_types": ["insert", "delete"]
680 }))
681 .unwrap();
682 let p = build_pipeline(&c).unwrap();
683 assert_eq!(p.len(), 1);
684 assert!(p[0].contains_key("$match"));
685 }
686
687 #[test]
688 fn pipeline_rejects_non_object_stage() {
689 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
690 "connection_uri": "mongodb://h/?replicaSet=rs0",
691 "scope": { "type": "cluster" },
692 "aggregation_pipeline": [[1, 2, 3]]
693 }))
694 .unwrap();
695 assert!(matches!(build_pipeline(&c), Err(FaucetError::Config(_))));
696 }
697
698 #[test]
699 fn pipeline_appends_object_stages_after_match() {
700 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
703 "connection_uri": "mongodb://h/?replicaSet=rs0",
704 "scope": { "type": "cluster" },
705 "operation_types": ["insert"],
706 "aggregation_pipeline": [
707 { "$match": { "fullDocument.tier": "gold" } },
708 { "$project": { "fullDocument._id": 1 } }
709 ]
710 }))
711 .unwrap();
712 let p = build_pipeline(&c).unwrap();
713 assert_eq!(p.len(), 3, "operation $match + two user stages");
714 assert!(
716 p[0].get_document("$match")
717 .unwrap()
718 .contains_key("operationType")
719 );
720 assert!(p[1].contains_key("$match"));
722 assert!(p[2].contains_key("$project"));
723 }
724
725 #[test]
726 fn pipeline_object_stages_without_operation_match() {
727 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
729 "connection_uri": "mongodb://h/?replicaSet=rs0",
730 "scope": { "type": "cluster" },
731 "aggregation_pipeline": [ { "$project": { "_id": 1 } } ]
732 }))
733 .unwrap();
734 let p = build_pipeline(&c).unwrap();
735 assert_eq!(p.len(), 1);
736 assert!(p[0].contains_key("$project"));
737 }
738
739 #[test]
740 fn full_document_type_mapping() {
741 use crate::config::FullDocument as F;
742 assert!(full_document_type(F::Off).is_none());
743 assert!(matches!(
744 full_document_type(F::WhenAvailable),
745 Some(FullDocumentType::WhenAvailable)
746 ));
747 assert!(matches!(
748 full_document_type(F::Required),
749 Some(FullDocumentType::Required)
750 ));
751 assert!(matches!(
752 full_document_type(F::UpdateLookup),
753 Some(FullDocumentType::UpdateLookup)
754 ));
755 }
756
757 #[test]
758 fn full_document_before_type_mapping() {
759 use crate::config::FullDocumentBeforeChange as F;
760 assert!(full_document_before_type(F::Off).is_none());
761 assert!(matches!(
762 full_document_before_type(F::WhenAvailable),
763 Some(FullDocumentBeforeChangeType::WhenAvailable)
764 ));
765 assert!(matches!(
766 full_document_before_type(F::Required),
767 Some(FullDocumentBeforeChangeType::Required)
768 ));
769 }
770
771 #[test]
775 fn dataset_uri_cluster_scope() {
776 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
777 "connection_uri": "mongodb://u:p@h:27017/?replicaSet=rs0",
778 "scope": { "type": "cluster" }
779 }))
780 .unwrap();
781 use crate::config::Scope;
782 let base = faucet_core::redact_uri_credentials(&c.connection_uri);
783 let uri = match &c.scope {
784 Scope::Collection {
785 database,
786 collection,
787 } => format!("{base}/{database}/{collection}"),
788 Scope::Database { database } => format!("{base}/{database}"),
789 Scope::Cluster => base,
790 };
791 assert_eq!(uri, "mongodb://h:27017/?replicaSet=rs0");
792 }
793
794 #[test]
795 fn dataset_uri_collection_scope() {
796 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
797 "connection_uri": "mongodb://u:p@h:27017/?replicaSet=rs0",
798 "scope": { "type": "collection", "database": "mydb", "collection": "orders" }
799 }))
800 .unwrap();
801 use crate::config::Scope;
802 let base = faucet_core::redact_uri_credentials(&c.connection_uri);
803 let uri = match &c.scope {
804 Scope::Collection {
805 database,
806 collection,
807 } => format!("{base}/{database}/{collection}"),
808 Scope::Database { database } => format!("{base}/{database}"),
809 Scope::Cluster => base,
810 };
811 assert_eq!(uri, "mongodb://h:27017/?replicaSet=rs0/mydb/orders");
812 }
813}