1use crate::error::FaucetError;
4use crate::pipeline::StreamPage;
5use async_trait::async_trait;
6use futures_core::Stream;
7use serde_json::Value;
8use std::pin::Pin;
9
10#[async_trait]
12pub trait Source: Send + Sync {
13 async fn fetch_with_context(
21 &self,
22 context: &std::collections::HashMap<String, Value>,
23 ) -> Result<Vec<Value>, FaucetError>;
24
25 async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
27 self.fetch_with_context(&std::collections::HashMap::new())
28 .await
29 }
30
31 async fn fetch_with_context_incremental(
37 &self,
38 context: &std::collections::HashMap<String, Value>,
39 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
40 let records = self.fetch_with_context(context).await?;
41 Ok((records, None))
42 }
43
44 async fn fetch_all_incremental(&self) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
46 self.fetch_with_context_incremental(&std::collections::HashMap::new())
47 .await
48 }
49
50 fn stream_pages<'a>(
74 &'a self,
75 context: &'a std::collections::HashMap<String, Value>,
76 batch_size: usize,
77 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
78 Box::pin(async_stream::try_stream! {
79 let (records, bookmark) = self
80 .fetch_with_context_incremental(context)
81 .await?;
82 let total = records.len();
83 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
86
87 if total == 0 {
88 if bookmark.is_some() {
89 yield StreamPage {
90 records: Vec::new(),
91 bookmark,
92 };
93 }
94 return;
95 }
96
97 let mut iter = records.into_iter();
98 let mut consumed = 0usize;
99 loop {
100 let batch: Vec<Value> = iter.by_ref().take(chunk).collect();
101 if batch.is_empty() {
102 break;
103 }
104 consumed += batch.len();
105 let page_bookmark = if consumed >= total {
106 bookmark.clone()
107 } else {
108 None
109 };
110 yield StreamPage {
111 records: batch,
112 bookmark: page_bookmark,
113 };
114 }
115 })
116 }
117
118 fn config_schema(&self) -> Value {
120 serde_json::json!({"type": "object", "properties": {}})
121 }
122
123 fn state_key(&self) -> Option<String> {
135 None
136 }
137
138 async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
147 Ok(())
148 }
149
150 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
162 Ok(None)
163 }
164
165 fn supports_exactly_once(&self) -> bool {
175 false
176 }
177
178 fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
187 if self.supports_exactly_once() {
188 crate::idempotency::ReplayGuarantee::Deterministic
189 } else {
190 crate::idempotency::ReplayGuarantee::NonDeterministic
191 }
192 }
193
194 fn is_shardable(&self) -> bool {
201 false
202 }
203
204 async fn enumerate_shards(
216 &self,
217 _target: usize,
218 ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
219 Ok(vec![crate::shard::ShardSpec::whole()])
220 }
221
222 async fn apply_shard(&self, _shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
232 Ok(())
233 }
234
235 fn supports_discover(&self) -> bool {
241 false
242 }
243
244 async fn discover(&self) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
256 Err(FaucetError::Source(format!(
257 "source '{}' does not support dataset discovery",
258 self.connector_name()
259 )))
260 }
261
262 fn connector_name(&self) -> &'static str {
270 crate::observability::strip_type_name(std::any::type_name::<Self>())
271 }
272
273 fn dataset_uri(&self) -> String {
281 format!("{}://unknown", self.connector_name())
282 }
283
284 async fn check(
297 &self,
298 ctx: &crate::check::CheckContext,
299 ) -> Result<crate::check::CheckReport, FaucetError> {
300 use crate::check::{CheckReport, Probe};
301 use futures::StreamExt;
302
303 let empty = std::collections::HashMap::new();
304 let start = std::time::Instant::now();
305 let mut pages = self.stream_pages(&empty, 1);
306 let probe = match tokio::time::timeout(ctx.timeout, pages.next()).await {
307 Err(_) => Probe::fail("read", start.elapsed(), "timed out fetching first page"),
308 Ok(None) | Ok(Some(Ok(_))) => Probe::pass("read", start.elapsed()),
309 Ok(Some(Err(e))) => Probe::fail("read", start.elapsed(), e.to_string()),
310 };
311 Ok(CheckReport::single(probe))
312 }
313}
314
315pub type RowOutcome = Result<(), FaucetError>;
321
322#[async_trait]
324pub trait Sink: Send + Sync {
325 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError>;
329
330 async fn flush(&self) -> Result<(), FaucetError> {
335 Ok(())
336 }
337
338 async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
347 self.write_batch(records).await?;
348 Ok(records.iter().map(|_| Ok(())).collect())
349 }
350
351 fn supports_idempotent_writes(&self) -> bool {
358 false
359 }
360
361 fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
371 if self.supports_idempotent_writes() {
372 crate::idempotency::SinkGuarantee::AtomicWatermark
373 } else if self
374 .supported_write_modes()
375 .contains(&crate::write_mode::WriteMode::Upsert)
376 {
377 crate::idempotency::SinkGuarantee::KeyedUpsert
378 } else {
379 crate::idempotency::SinkGuarantee::AtLeastOnce
380 }
381 }
382
383 fn dedups_by_key(&self) -> bool {
394 false
395 }
396
397 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
403 &[crate::write_mode::WriteMode::Append]
404 }
405
406 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
413 Ok(None)
414 }
415
416 fn supports_schema_evolution(&self) -> bool {
420 false
421 }
422
423 async fn evolve_schema(
431 &self,
432 evolution: &crate::drift::SchemaEvolution,
433 ) -> Result<(), FaucetError> {
434 let _ = evolution;
435 Err(FaucetError::Sink(format!(
436 "sink '{}' does not support schema evolution",
437 self.connector_name()
438 )))
439 }
440
441 async fn write_batch_idempotent(
451 &self,
452 records: &[Value],
453 scope: &str,
454 token: &str,
455 ) -> Result<usize, FaucetError> {
456 let _ = (scope, token);
457 self.write_batch(records).await
458 }
459
460 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
463 let _ = scope;
464 Ok(None)
465 }
466
467 fn config_schema(&self) -> Value {
475 serde_json::json!({"type": "object", "properties": {}})
476 }
477
478 fn connector_name(&self) -> &'static str {
481 crate::observability::strip_type_name(std::any::type_name::<Self>())
482 }
483
484 fn dataset_uri(&self) -> String {
492 format!("{}://unknown", self.connector_name())
493 }
494
495 async fn check(
506 &self,
507 _ctx: &crate::check::CheckContext,
508 ) -> Result<crate::check::CheckReport, FaucetError> {
509 Ok(crate::check::CheckReport::not_implemented())
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use serde_json::json;
517
518 struct MockSource {
521 records: Vec<Value>,
522 }
523
524 #[async_trait]
525 impl Source for MockSource {
526 async fn fetch_with_context(
527 &self,
528 _context: &std::collections::HashMap<String, Value>,
529 ) -> Result<Vec<Value>, FaucetError> {
530 Ok(self.records.clone())
531 }
532 }
533
534 struct IncrementalSource {
535 records: Vec<Value>,
536 bookmark: Value,
537 }
538
539 #[async_trait]
540 impl Source for IncrementalSource {
541 async fn fetch_with_context(
542 &self,
543 _context: &std::collections::HashMap<String, Value>,
544 ) -> Result<Vec<Value>, FaucetError> {
545 Ok(self.records.clone())
546 }
547
548 async fn fetch_with_context_incremental(
549 &self,
550 _context: &std::collections::HashMap<String, Value>,
551 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
552 Ok((self.records.clone(), Some(self.bookmark.clone())))
553 }
554 }
555
556 struct FailingSource;
557
558 #[async_trait]
559 impl Source for FailingSource {
560 async fn fetch_with_context(
561 &self,
562 _context: &std::collections::HashMap<String, Value>,
563 ) -> Result<Vec<Value>, FaucetError> {
564 Err(FaucetError::Auth("no credentials".into()))
565 }
566 }
567
568 struct MockSink {
571 written: std::sync::Mutex<Vec<Value>>,
572 }
573
574 impl MockSink {
575 fn new() -> Self {
576 Self {
577 written: std::sync::Mutex::new(Vec::new()),
578 }
579 }
580 }
581
582 #[async_trait]
583 impl Sink for MockSink {
584 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
585 let mut w = self.written.lock().unwrap();
586 w.extend(records.iter().cloned());
587 Ok(records.len())
588 }
589 }
590
591 struct FailingSink;
592
593 #[async_trait]
594 impl Sink for FailingSink {
595 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
596 Err(FaucetError::Sink("write failed".into()))
597 }
598 }
599
600 #[tokio::test]
603 async fn source_fetch_all_returns_records() {
604 let source = MockSource {
605 records: vec![json!({"id": 1}), json!({"id": 2})],
606 };
607 let records = source.fetch_all().await.unwrap();
608 assert_eq!(records.len(), 2);
609 assert_eq!(records[0]["id"], 1);
610 }
611
612 #[tokio::test]
613 async fn source_fetch_all_empty() {
614 let source = MockSource { records: vec![] };
615 let records = source.fetch_all().await.unwrap();
616 assert!(records.is_empty());
617 }
618
619 #[tokio::test]
620 async fn source_default_incremental_returns_none_bookmark() {
621 let source = MockSource {
622 records: vec![json!({"id": 1})],
623 };
624 let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
625 assert_eq!(records.len(), 1);
626 assert!(bookmark.is_none());
627 }
628
629 #[tokio::test]
630 async fn source_custom_incremental_returns_bookmark() {
631 let source = IncrementalSource {
632 records: vec![json!({"id": 1})],
633 bookmark: json!("2024-12-01"),
634 };
635 let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
636 assert_eq!(records.len(), 1);
637 assert_eq!(bookmark, Some(json!("2024-12-01")));
638 }
639
640 #[tokio::test]
641 async fn source_error_propagates() {
642 let source = FailingSource;
643 let result = source.fetch_all().await;
644 assert!(result.is_err());
645 assert!(matches!(result, Err(FaucetError::Auth(_))));
646 }
647
648 #[tokio::test]
649 async fn source_as_trait_object() {
650 let source: Box<dyn Source> = Box::new(MockSource {
651 records: vec![json!({"id": 42})],
652 });
653 let records = source.fetch_all().await.unwrap();
654 assert_eq!(records[0]["id"], 42);
655 }
656
657 #[tokio::test]
660 async fn sink_write_batch_returns_count() {
661 let sink = MockSink::new();
662 let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
663 let count = sink.write_batch(&records).await.unwrap();
664 assert_eq!(count, 3);
665 }
666
667 #[tokio::test]
668 async fn sink_write_batch_empty() {
669 let sink = MockSink::new();
670 let count = sink.write_batch(&[]).await.unwrap();
671 assert_eq!(count, 0);
672 }
673
674 #[tokio::test]
675 async fn sink_accumulates_records() {
676 let sink = MockSink::new();
677 sink.write_batch(&[json!({"a": 1})]).await.unwrap();
678 sink.write_batch(&[json!({"b": 2})]).await.unwrap();
679 let written = sink.written.lock().unwrap();
680 assert_eq!(written.len(), 2);
681 }
682
683 #[tokio::test]
684 async fn sink_default_flush_is_noop() {
685 let sink = MockSink::new();
686 assert!(sink.flush().await.is_ok());
687 }
688
689 #[tokio::test]
690 async fn sink_error_propagates() {
691 let sink = FailingSink;
692 let result = sink.write_batch(&[json!({"id": 1})]).await;
693 assert!(result.is_err());
694 assert!(matches!(result, Err(FaucetError::Sink(_))));
695 }
696
697 #[tokio::test]
698 async fn sink_as_trait_object() {
699 let sink: Box<dyn Sink> = Box::new(MockSink::new());
700 let count = sink.write_batch(&[json!({"id": 1})]).await.unwrap();
701 assert_eq!(count, 1);
702 }
703
704 use crate::pipeline::DEFAULT_BATCH_SIZE;
707 use futures::StreamExt;
708
709 #[tokio::test]
710 async fn default_stream_pages_chunks_records() {
711 let source = MockSource {
712 records: (0..5).map(|i| json!({"i": i})).collect(),
713 };
714 let ctx = std::collections::HashMap::new();
715 let mut pages = source.stream_pages(&ctx, 2);
716 let mut all = Vec::new();
717 while let Some(page) = pages.next().await {
718 all.push(page.unwrap());
719 }
720 assert_eq!(all.len(), 3);
722 assert_eq!(all[0].records.len(), 2);
723 assert_eq!(all[1].records.len(), 2);
724 assert_eq!(all[2].records.len(), 1);
725 }
726
727 #[tokio::test]
728 async fn default_stream_pages_attaches_bookmark_to_final_page_only() {
729 let source = IncrementalSource {
730 records: (0..5).map(|i| json!({"i": i})).collect(),
731 bookmark: json!("v1"),
732 };
733 let ctx = std::collections::HashMap::new();
734 let mut pages = source.stream_pages(&ctx, 2);
735 let mut collected = Vec::new();
736 while let Some(page) = pages.next().await {
737 collected.push(page.unwrap());
738 }
739 assert_eq!(collected.len(), 3);
740 assert!(collected[0].bookmark.is_none());
741 assert!(collected[1].bookmark.is_none());
742 assert_eq!(collected[2].bookmark, Some(json!("v1")));
743 }
744
745 #[tokio::test]
746 async fn default_stream_pages_single_page_when_batch_size_exceeds_total() {
747 let source = MockSource {
748 records: vec![json!({"id": 1}), json!({"id": 2})],
749 };
750 let ctx = std::collections::HashMap::new();
751 let mut pages = source.stream_pages(&ctx, 100);
752 let mut collected = Vec::new();
753 while let Some(page) = pages.next().await {
754 collected.push(page.unwrap());
755 }
756 assert_eq!(collected.len(), 1);
757 assert_eq!(collected[0].records.len(), 2);
758 }
759
760 #[tokio::test]
761 async fn default_stream_pages_batch_size_zero_emits_single_page() {
762 let source = MockSource {
765 records: (0..50_000).map(|i| json!({"i": i})).collect(),
766 };
767 let ctx = std::collections::HashMap::new();
768 let mut pages = source.stream_pages(&ctx, 0);
769 let mut collected = Vec::new();
770 while let Some(page) = pages.next().await {
771 collected.push(page.unwrap());
772 }
773 assert_eq!(
774 collected.len(),
775 1,
776 "batch_size=0 must emit exactly one page"
777 );
778 assert_eq!(collected[0].records.len(), 50_000);
779 }
780
781 #[tokio::test]
782 async fn default_stream_pages_batch_size_zero_attaches_bookmark_to_sole_page() {
783 let source = IncrementalSource {
784 records: (0..3).map(|i| json!({"i": i})).collect(),
785 bookmark: json!("v1"),
786 };
787 let ctx = std::collections::HashMap::new();
788 let mut pages = source.stream_pages(&ctx, 0);
789 let page = pages.next().await.unwrap().unwrap();
790 assert_eq!(page.records.len(), 3);
791 assert_eq!(page.bookmark, Some(json!("v1")));
792 assert!(pages.next().await.is_none());
793 }
794
795 #[tokio::test]
796 async fn default_stream_pages_empty_source_yields_no_pages() {
797 let source = MockSource { records: vec![] };
798 let ctx = std::collections::HashMap::new();
799 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
800 assert!(pages.next().await.is_none());
801 }
802
803 #[tokio::test]
804 async fn default_stream_pages_empty_source_with_bookmark_yields_single_empty_page() {
805 let source = IncrementalSource {
806 records: vec![],
807 bookmark: json!("v0"),
808 };
809 let ctx = std::collections::HashMap::new();
810 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
811 let mut collected = Vec::new();
812 while let Some(page) = pages.next().await {
813 collected.push(page.unwrap());
814 }
815 assert_eq!(collected.len(), 1);
818 assert!(collected[0].records.is_empty());
819 assert_eq!(collected[0].bookmark, Some(json!("v0")));
820 }
821
822 #[tokio::test]
823 async fn default_stream_pages_propagates_fetch_errors() {
824 let source = FailingSource;
825 let ctx = std::collections::HashMap::new();
826 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
827 let first = pages.next().await.unwrap();
828 assert!(matches!(first, Err(FaucetError::Auth(_))));
829 }
830
831 #[test]
832 fn source_default_connector_name_is_stripped_type_name() {
833 let source = MockSource { records: vec![] };
836 assert_eq!(source.connector_name(), "MockSource");
837 }
838
839 #[test]
840 fn sink_default_connector_name_is_stripped_type_name() {
841 let sink = MockSink::new();
842 assert_eq!(sink.connector_name(), "MockSink");
843 }
844
845 #[test]
846 fn source_default_dataset_uri_uses_connector_name() {
847 let source = MockSource { records: vec![] };
848 assert_eq!(source.dataset_uri(), "MockSource://unknown");
849 }
850
851 #[test]
852 fn sink_default_dataset_uri_uses_connector_name() {
853 let sink = MockSink::new();
854 assert_eq!(sink.dataset_uri(), "MockSink://unknown");
855 }
856
857 #[tokio::test]
860 async fn default_write_batch_partial_success_returns_all_ok() {
861 let sink = MockSink::new();
862 let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
863 let outcomes = sink.write_batch_partial(&records).await.unwrap();
864 assert_eq!(outcomes.len(), 3);
865 assert!(outcomes.iter().all(|o| o.is_ok()));
866 assert_eq!(sink.written.lock().unwrap().len(), 3);
867 }
868
869 #[tokio::test]
870 async fn default_write_batch_partial_bubbles_outer_err() {
871 let sink = FailingSink;
872 let records = vec![json!({"id": 1}), json!({"id": 2})];
873 let result = sink.write_batch_partial(&records).await;
874 assert!(matches!(result, Err(FaucetError::Sink(_))));
875 }
876
877 #[tokio::test]
878 async fn default_write_batch_partial_empty_returns_empty_vec() {
879 let sink = MockSink::new();
880 let outcomes = sink.write_batch_partial(&[]).await.unwrap();
881 assert!(outcomes.is_empty());
882 }
883
884 #[tokio::test]
885 async fn default_write_batch_partial_callable_through_trait_object() {
886 let sink: Box<dyn Sink> = Box::new(MockSink::new());
887 let records = vec![json!({"id": 1}), json!({"id": 2})];
888 let outcomes = sink.write_batch_partial(&records).await.unwrap();
889 assert_eq!(outcomes.len(), 2);
890 assert!(outcomes.iter().all(|o| o.is_ok()));
891 }
892
893 #[tokio::test]
896 async fn source_default_check_pulls_first_page_and_passes() {
897 let source = MockSource {
898 records: vec![json!({"id": 1}), json!({"id": 2})],
899 };
900 let report = source
901 .check(&crate::check::CheckContext::default())
902 .await
903 .unwrap();
904 assert_eq!(report.failed_count(), 0);
905 assert!(
906 report
907 .probes
908 .iter()
909 .any(|p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Pass))
910 );
911 }
912
913 #[tokio::test]
914 async fn source_default_check_passes_on_empty_source() {
915 let source = MockSource { records: vec![] };
916 let report = source
917 .check(&crate::check::CheckContext::default())
918 .await
919 .unwrap();
920 assert_eq!(report.failed_count(), 0);
922 }
923
924 #[tokio::test]
925 async fn source_default_check_fails_when_fetch_errors() {
926 let source = FailingSource;
927 let report = source
928 .check(&crate::check::CheckContext::default())
929 .await
930 .unwrap();
931 assert_eq!(report.failed_count(), 1);
932 assert!(report.probes.iter().any(
933 |p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Fail { .. })
934 ));
935 }
936
937 #[tokio::test]
938 async fn sink_default_check_is_not_implemented_skip() {
939 let sink = MockSink::new();
940 let report = sink
941 .check(&crate::check::CheckContext::default())
942 .await
943 .unwrap();
944 assert_eq!(report.probes.len(), 1);
945 assert!(matches!(
946 report.probes[0].status,
947 crate::check::ProbeStatus::Skip { .. }
948 ));
949 }
950
951 #[tokio::test]
952 async fn source_check_callable_through_trait_object() {
953 let source: Box<dyn Source> = Box::new(MockSource {
954 records: vec![json!({"id": 1})],
955 });
956 let report = source
957 .check(&crate::check::CheckContext::default())
958 .await
959 .unwrap();
960 assert_eq!(report.failed_count(), 0);
961 }
962
963 #[tokio::test]
966 async fn sink_default_is_not_idempotent() {
967 let sink = MockSink::new();
968 assert!(!sink.supports_idempotent_writes());
969 let n = sink
971 .write_batch_idempotent(&[json!({"id": 1})], "scope::a", "00000000000000000001")
972 .await
973 .unwrap();
974 assert_eq!(n, 1);
975 assert_eq!(sink.last_committed_token("scope::a").await.unwrap(), None);
976 assert_eq!(sink.written.lock().unwrap().len(), 1);
977 }
978
979 #[test]
980 fn source_default_does_not_support_exactly_once() {
981 let source = MockSource { records: vec![] };
982 assert!(!source.supports_exactly_once());
983 }
984
985 #[test]
986 fn sink_default_supported_write_modes_is_append_only() {
987 use crate::write_mode::WriteMode;
988 let sink = MockSink::new();
989 assert_eq!(sink.supported_write_modes(), &[WriteMode::Append]);
990 }
991
992 #[test]
993 fn supported_write_modes_callable_through_trait_object() {
994 use crate::write_mode::WriteMode;
995 let sink: Box<dyn Sink> = Box::new(MockSink::new());
996 assert!(sink.supported_write_modes().contains(&WriteMode::Append));
997 }
998
999 #[tokio::test]
1000 async fn sink_default_current_schema_is_none() {
1001 let sink = MockSink::new();
1002 assert_eq!(sink.current_schema().await.unwrap(), None);
1003 }
1004
1005 #[test]
1006 fn sink_default_does_not_support_schema_evolution() {
1007 let sink = MockSink::new();
1008 assert!(!sink.supports_schema_evolution());
1009 }
1010
1011 #[tokio::test]
1012 async fn sink_default_evolve_schema_is_unsupported_error() {
1013 let sink = MockSink::new();
1014 let evo = crate::drift::SchemaEvolution::default();
1015 let err = sink.evolve_schema(&evo).await.unwrap_err();
1016 assert!(matches!(err, FaucetError::Sink(_)));
1017 assert!(err.to_string().contains("schema evolution"));
1018 }
1019
1020 #[tokio::test]
1021 async fn source_default_capture_resume_position_is_none() {
1022 let source = MockSource { records: vec![] };
1023 assert_eq!(source.capture_resume_position().await.unwrap(), None);
1024 }
1025
1026 #[tokio::test]
1027 async fn capture_resume_position_callable_through_trait_object() {
1028 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1029 assert!(source.capture_resume_position().await.unwrap().is_none());
1030 }
1031
1032 #[tokio::test]
1033 async fn source_default_does_not_support_discover() {
1034 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1035 assert!(!source.supports_discover());
1036 let err = source.discover().await.unwrap_err();
1037 assert!(matches!(err, FaucetError::Source(_)));
1038 assert!(
1039 err.to_string().contains("dataset discovery"),
1040 "typed unsupported error: {err}"
1041 );
1042 }
1043
1044 #[tokio::test]
1045 async fn source_default_is_not_shardable() {
1046 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1047 assert!(!source.is_shardable());
1048 }
1049
1050 #[tokio::test]
1051 async fn source_default_enumerates_single_whole_shard() {
1052 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1055 let shards = source.enumerate_shards(8).await.unwrap();
1056 assert_eq!(shards.len(), 1);
1057 assert!(shards[0].is_whole());
1058 }
1059
1060 #[tokio::test]
1061 async fn source_default_apply_shard_is_noop() {
1062 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1063 source
1065 .apply_shard(&crate::shard::ShardSpec::whole())
1066 .await
1067 .unwrap();
1068 }
1069}