1use std::collections::HashMap;
14use std::pin::Pin;
15use std::sync::{Arc, Mutex};
16
17use faucet_core::check::{CheckContext, CheckReport};
18use faucet_core::drift::SchemaEvolution;
19use faucet_core::write_mode::{DeleteMarker, WriteMode};
20use faucet_core::{FaucetError, Sink, Source, StreamPage, Value, async_trait};
21use futures_core::Stream;
22use serde_json::json;
23
24pub const DELETE_MARKER_FIELD: &str = "__op";
30pub const DELETE_MARKER_VALUE: &str = "d";
32
33pub struct CountingSource {
44 total: usize,
45 batch: usize,
46 resumable: bool,
47 start: Arc<Mutex<usize>>,
48}
49
50impl CountingSource {
51 pub fn new(total: usize, batch: usize) -> Self {
53 Self {
54 total,
55 batch,
56 resumable: true,
57 start: Arc::new(Mutex::new(0)),
58 }
59 }
60
61 pub fn non_resumable(total: usize, batch: usize) -> Self {
64 Self {
65 total,
66 batch,
67 resumable: false,
68 start: Arc::new(Mutex::new(0)),
69 }
70 }
71}
72
73#[async_trait]
74impl Source for CountingSource {
75 async fn fetch_with_context(
76 &self,
77 _context: &HashMap<String, Value>,
78 ) -> Result<Vec<Value>, FaucetError> {
79 let start = *self.start.lock().unwrap();
80 Ok((start..self.total).map(|i| json!({ "n": i })).collect())
81 }
82
83 fn stream_pages<'a>(
84 &'a self,
85 _context: &'a HashMap<String, Value>,
86 _batch_size: usize,
87 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
88 let batch = if self.batch == 0 {
93 self.total.max(1)
94 } else {
95 self.batch
96 };
97 let total = self.total;
98 let start = (*self.start.lock().unwrap()).min(total);
99 Box::pin(async_stream::try_stream! {
100 let mut n = start;
101 if n >= total {
102 yield StreamPage { records: Vec::new(), bookmark: Some(json!({ "n": total })) };
105 return;
106 }
107 while n < total {
108 let end = (n + batch).min(total);
109 let records: Vec<Value> = (n..end).map(|i| json!({ "n": i })).collect();
110 n = end;
111 let bookmark = if n >= total { Some(json!({ "n": total })) } else { None };
112 yield StreamPage { records, bookmark };
113 }
114 })
115 }
116
117 fn connector_name(&self) -> &'static str {
118 "counting-source"
119 }
120
121 fn state_key(&self) -> Option<String> {
122 Some("conformance:counting".to_string())
123 }
124
125 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
126 if !self.resumable {
127 return Ok(());
128 }
129 if let Some(n) = bookmark.get("n").and_then(|v| v.as_u64()) {
130 *self.start.lock().unwrap() = n as usize;
131 }
132 Ok(())
133 }
134}
135
136pub struct FailingSource;
140
141#[async_trait]
142impl Source for FailingSource {
143 async fn fetch_with_context(
144 &self,
145 _context: &HashMap<String, Value>,
146 ) -> Result<Vec<Value>, FaucetError> {
147 Err(FaucetError::Source(
148 "unreachable endpoint (test double)".to_string(),
149 ))
150 }
151
152 fn connector_name(&self) -> &'static str {
153 "failing-source"
154 }
155}
156
157pub struct PanickingSource;
161
162#[async_trait]
163impl Source for PanickingSource {
164 async fn fetch_with_context(
165 &self,
166 _context: &HashMap<String, Value>,
167 ) -> Result<Vec<Value>, FaucetError> {
168 panic!("connector bug: unwrap() on a None value");
169 }
170
171 fn connector_name(&self) -> &'static str {
172 "panicking-source"
173 }
174}
175
176#[derive(Clone, Default)]
191pub struct TestSink {
192 key_field: Option<String>,
193 idempotent: bool,
194 delete_marker: Option<DeleteMarker>,
195 keyed: Arc<Mutex<HashMap<String, Value>>>,
196 appended: Arc<Mutex<Vec<Value>>>,
197 tokens: Arc<Mutex<HashMap<String, String>>>,
198 write_calls: Arc<Mutex<usize>>,
199}
200
201impl TestSink {
202 pub fn new() -> Self {
204 Self::default()
205 }
206
207 pub fn keyed(key_field: impl Into<String>) -> Self {
209 Self {
210 key_field: Some(key_field.into()),
211 ..Self::default()
212 }
213 }
214
215 pub fn keyed_upsert(key_field: impl Into<String>) -> Self {
220 Self {
221 key_field: Some(key_field.into()),
222 delete_marker: Some(DeleteMarker {
223 field: DELETE_MARKER_FIELD.to_string(),
224 values: vec![DELETE_MARKER_VALUE.to_string()],
225 }),
226 ..Self::default()
227 }
228 }
229
230 pub fn idempotent(key_field: impl Into<String>) -> Self {
233 Self {
234 key_field: Some(key_field.into()),
235 idempotent: true,
236 ..Self::default()
237 }
238 }
239
240 pub fn len(&self) -> usize {
242 if self.key_field.is_some() {
243 self.keyed.lock().unwrap().len()
244 } else {
245 self.appended.lock().unwrap().len()
246 }
247 }
248
249 pub fn is_empty(&self) -> bool {
251 self.len() == 0
252 }
253
254 pub fn total_written(&self) -> usize {
257 *self.write_calls.lock().unwrap()
258 }
259
260 fn is_delete_marked(&self, record: &Value) -> bool {
262 match &self.delete_marker {
263 Some(dm) => record
264 .get(&dm.field)
265 .and_then(|v| v.as_str())
266 .is_some_and(|s| dm.values.iter().any(|m| m == s)),
267 None => false,
268 }
269 }
270}
271
272#[async_trait]
273impl Sink for TestSink {
274 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
275 *self.write_calls.lock().unwrap() += records.len();
276 match &self.key_field {
277 Some(field) => {
278 let mut map = self.keyed.lock().unwrap();
279 for r in records {
280 let key = r.get(field).map(|v| v.to_string()).ok_or_else(|| {
281 FaucetError::Sink(format!("record missing key `{field}`"))
282 })?;
283 if self.is_delete_marked(r) {
286 map.remove(&key);
287 } else {
288 map.insert(key, r.clone());
289 }
290 }
291 }
292 None => self
293 .appended
294 .lock()
295 .unwrap()
296 .extend(records.iter().cloned()),
297 }
298 Ok(records.len())
299 }
300
301 fn supports_idempotent_writes(&self) -> bool {
302 self.idempotent
303 }
304
305 fn dedups_by_key(&self) -> bool {
306 self.key_field.is_some()
307 }
308
309 fn supported_write_modes(&self) -> &'static [WriteMode] {
310 if self.key_field.is_some() {
311 &[WriteMode::Append, WriteMode::Upsert, WriteMode::Delete]
312 } else {
313 &[WriteMode::Append]
314 }
315 }
316
317 async fn write_batch_idempotent(
318 &self,
319 records: &[Value],
320 scope: &str,
321 token: &str,
322 ) -> Result<usize, FaucetError> {
323 self.tokens
327 .lock()
328 .unwrap()
329 .insert(scope.to_string(), token.to_string());
330 self.write_batch(records).await
331 }
332
333 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
334 Ok(self.tokens.lock().unwrap().get(scope).cloned())
335 }
336
337 fn connector_name(&self) -> &'static str {
338 "test-sink"
339 }
340}
341
342#[derive(Clone, Default)]
346pub struct LyingIdempotentSink {
347 appended: Arc<Mutex<Vec<Value>>>,
348}
349
350impl LyingIdempotentSink {
351 pub fn new() -> Self {
353 Self::default()
354 }
355 pub fn len(&self) -> usize {
357 self.appended.lock().unwrap().len()
358 }
359 pub fn is_empty(&self) -> bool {
361 self.len() == 0
362 }
363}
364
365#[async_trait]
366impl Sink for LyingIdempotentSink {
367 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
368 self.appended
369 .lock()
370 .unwrap()
371 .extend(records.iter().cloned());
372 Ok(records.len())
373 }
374
375 fn supports_idempotent_writes(&self) -> bool {
376 true }
378
379 fn connector_name(&self) -> &'static str {
380 "lying-idempotent-sink"
381 }
382}
383
384#[derive(Clone, Default)]
388pub struct LyingKeyedSink {
389 appended: Arc<Mutex<Vec<Value>>>,
390}
391
392impl LyingKeyedSink {
393 pub fn new() -> Self {
395 Self::default()
396 }
397 pub fn len(&self) -> usize {
399 self.appended.lock().unwrap().len()
400 }
401 pub fn is_empty(&self) -> bool {
403 self.len() == 0
404 }
405}
406
407#[async_trait]
408impl Sink for LyingKeyedSink {
409 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
410 self.appended
411 .lock()
412 .unwrap()
413 .extend(records.iter().cloned());
414 Ok(records.len())
415 }
416
417 fn dedups_by_key(&self) -> bool {
418 true }
420
421 fn supported_write_modes(&self) -> &'static [WriteMode] {
422 &[WriteMode::Append, WriteMode::Upsert]
423 }
424
425 fn connector_name(&self) -> &'static str {
426 "lying-keyed-sink"
427 }
428}
429
430#[derive(Clone)]
437pub struct EvolvingSink {
438 columns: Arc<Mutex<HashMap<String, Value>>>,
440}
441
442impl Default for EvolvingSink {
443 fn default() -> Self {
444 let mut cols = HashMap::new();
445 cols.insert("id".to_string(), json!({ "type": "integer" }));
446 Self {
447 columns: Arc::new(Mutex::new(cols)),
448 }
449 }
450}
451
452impl EvolvingSink {
453 pub fn new() -> Self {
455 Self::default()
456 }
457
458 pub fn column_count(&self) -> usize {
460 self.columns.lock().unwrap().len()
461 }
462}
463
464fn schema_from_columns(cols: &HashMap<String, Value>) -> Value {
466 let props: serde_json::Map<String, Value> =
467 cols.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
468 json!({ "type": "object", "properties": props })
469}
470
471#[async_trait]
472impl Sink for EvolvingSink {
473 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
474 Ok(records.len())
475 }
476
477 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
478 Ok(Some(schema_from_columns(&self.columns.lock().unwrap())))
479 }
480
481 fn supports_schema_evolution(&self) -> bool {
482 true
483 }
484
485 async fn evolve_schema(&self, evolution: &SchemaEvolution) -> Result<(), FaucetError> {
486 let mut cols = self.columns.lock().unwrap();
487 for change in evolution.additions.iter().chain(&evolution.widenings) {
488 cols.insert(change.name.clone(), change.to.clone());
489 }
490 Ok(())
491 }
492
493 fn connector_name(&self) -> &'static str {
494 "evolving-sink"
495 }
496}
497
498#[derive(Clone, Default)]
504pub struct NoOpEvolvingSink;
505
506#[async_trait]
507impl Sink for NoOpEvolvingSink {
508 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
509 Ok(records.len())
510 }
511
512 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
513 Ok(Some(json!({
514 "type": "object",
515 "properties": { "id": { "type": "integer" } }
516 })))
517 }
518
519 fn supports_schema_evolution(&self) -> bool {
520 true }
522
523 async fn evolve_schema(&self, _evolution: &SchemaEvolution) -> Result<(), FaucetError> {
524 Ok(()) }
526
527 fn connector_name(&self) -> &'static str {
528 "noop-evolving-sink"
529 }
530}
531
532pub struct MultiPageZeroSource {
538 total: usize,
539 page: usize,
540}
541
542impl MultiPageZeroSource {
543 pub fn new(total: usize) -> Self {
546 Self { total, page: 2 }
547 }
548}
549
550#[async_trait]
551impl Source for MultiPageZeroSource {
552 async fn fetch_with_context(
553 &self,
554 _context: &HashMap<String, Value>,
555 ) -> Result<Vec<Value>, FaucetError> {
556 Ok((0..self.total).map(|i| json!({ "n": i })).collect())
557 }
558
559 fn stream_pages<'a>(
560 &'a self,
561 _context: &'a HashMap<String, Value>,
562 _batch_size: usize,
563 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
564 let total = self.total;
566 let page = self.page.max(1);
567 Box::pin(async_stream::try_stream! {
568 let mut n = 0;
569 while n < total {
570 let end = (n + page).min(total);
571 let records: Vec<Value> = (n..end).map(|i| json!({ "n": i })).collect();
572 n = end;
573 let bookmark = if n >= total { Some(json!({ "n": total })) } else { None };
574 yield StreamPage { records, bookmark };
575 }
576 })
577 }
578
579 fn connector_name(&self) -> &'static str {
580 "multi-page-zero-source"
581 }
582}
583
584pub struct EmptyNameSource;
589
590#[async_trait]
591impl Source for EmptyNameSource {
592 async fn fetch_with_context(
593 &self,
594 _context: &HashMap<String, Value>,
595 ) -> Result<Vec<Value>, FaucetError> {
596 Ok(Vec::new())
597 }
598
599 fn connector_name(&self) -> &'static str {
600 "" }
602}
603
604pub struct ErringCheckSource;
610
611#[async_trait]
612impl Source for ErringCheckSource {
613 async fn fetch_with_context(
614 &self,
615 _context: &HashMap<String, Value>,
616 ) -> Result<Vec<Value>, FaucetError> {
617 Ok(Vec::new())
618 }
619
620 async fn check(&self, _ctx: &CheckContext) -> Result<CheckReport, FaucetError> {
621 Err(FaucetError::Source(
622 "probe failed — but returned as Err instead of a Fail probe".to_string(),
623 ))
624 }
625
626 fn connector_name(&self) -> &'static str {
627 "erring-check-source"
628 }
629}
630
631#[derive(Clone, Default)]
635pub struct ErringCheckSink;
636
637#[async_trait]
638impl Sink for ErringCheckSink {
639 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
640 Ok(records.len())
641 }
642
643 async fn check(&self, _ctx: &CheckContext) -> Result<CheckReport, FaucetError> {
644 Err(FaucetError::Sink(
645 "probe failed — but returned as Err instead of a Fail probe".to_string(),
646 ))
647 }
648
649 fn connector_name(&self) -> &'static str {
650 "erring-check-sink"
651 }
652}
653
654pub struct DiscoverableSource {
668 datasets: Vec<String>,
669}
670
671impl DiscoverableSource {
672 pub fn new() -> Self {
674 Self {
675 datasets: vec!["orders".to_string(), "customers".to_string()],
676 }
677 }
678
679 pub fn empty() -> Self {
682 Self {
683 datasets: Vec::new(),
684 }
685 }
686}
687
688impl Default for DiscoverableSource {
689 fn default() -> Self {
690 Self::new()
691 }
692}
693
694#[async_trait]
695impl Source for DiscoverableSource {
696 async fn fetch_with_context(
697 &self,
698 _context: &HashMap<String, Value>,
699 ) -> Result<Vec<Value>, FaucetError> {
700 Ok(Vec::new())
701 }
702
703 fn supports_discover(&self) -> bool {
704 true
705 }
706
707 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
708 Ok(self
709 .datasets
710 .iter()
711 .map(|name| {
712 faucet_core::DatasetDescriptor::new(
713 name.clone(),
714 "table",
715 json!({ "dataset": name }),
716 )
717 })
718 .collect())
719 }
720
721 fn connector_name(&self) -> &'static str {
722 "discoverable-source"
723 }
724}
725
726#[derive(Clone)]
738pub struct BufferedSink {
739 staged: Arc<Mutex<Vec<Value>>>,
740 durable: Arc<Mutex<Vec<Value>>>,
741 commit_on_flush: bool,
742}
743
744impl BufferedSink {
745 pub fn new() -> Self {
747 Self {
748 staged: Arc::new(Mutex::new(Vec::new())),
749 durable: Arc::new(Mutex::new(Vec::new())),
750 commit_on_flush: true,
751 }
752 }
753
754 pub fn broken() -> Self {
757 Self {
758 commit_on_flush: false,
759 ..Self::new()
760 }
761 }
762
763 pub fn durable_len(&self) -> usize {
765 self.durable.lock().unwrap().len()
766 }
767
768 pub fn staged_len(&self) -> usize {
770 self.staged.lock().unwrap().len()
771 }
772}
773
774impl Default for BufferedSink {
775 fn default() -> Self {
776 Self::new()
777 }
778}
779
780#[async_trait]
781impl Sink for BufferedSink {
782 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
783 self.staged.lock().unwrap().extend(records.iter().cloned());
784 Ok(records.len())
785 }
786
787 async fn flush(&self) -> Result<(), FaucetError> {
788 if self.commit_on_flush {
789 let mut staged = self.staged.lock().unwrap();
790 self.durable.lock().unwrap().extend(staged.drain(..));
791 }
792 Ok(())
793 }
794
795 fn connector_name(&self) -> &'static str {
796 "buffered-sink"
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use faucet_core::drift::ColumnChange;
804 use futures::StreamExt;
805 use serde_json::json;
806 use std::collections::HashMap;
807
808 #[tokio::test]
809 async fn counting_source_resumes_and_ignores_when_non_resumable() {
810 let s = CountingSource::new(5, 2);
811 assert_eq!(s.state_key().as_deref(), Some("conformance:counting"));
812 assert_eq!(s.connector_name(), "counting-source");
813 assert_eq!(
814 s.fetch_with_context(&HashMap::new()).await.unwrap().len(),
815 5
816 );
817 s.apply_start_bookmark(json!({ "n": 5 })).await.unwrap();
819 assert!(
820 s.fetch_with_context(&HashMap::new())
821 .await
822 .unwrap()
823 .is_empty()
824 );
825
826 let nr = CountingSource::non_resumable(5, 2);
828 nr.apply_start_bookmark(json!({ "n": 5 })).await.unwrap();
829 assert_eq!(
830 nr.fetch_with_context(&HashMap::new()).await.unwrap().len(),
831 5
832 );
833 }
834
835 #[tokio::test]
836 async fn test_sink_accessors() {
837 let s = TestSink::new();
838 assert!(s.is_empty());
839 s.write_batch(&[json!({ "id": 1 })]).await.unwrap();
840 assert!(!s.is_empty());
841 assert_eq!(s.len(), 1);
842 assert_eq!(s.total_written(), 1);
843 assert_eq!(s.connector_name(), "test-sink");
844 }
845
846 #[tokio::test]
847 async fn lying_idempotent_sink_never_persists_a_token() {
848 let s = LyingIdempotentSink::new();
849 assert!(s.is_empty());
850 assert!(s.supports_idempotent_writes());
851 assert_eq!(s.connector_name(), "lying-idempotent-sink");
852 s.write_batch_idempotent(&[json!({ "id": 1 })], "scope", "00000000000000000001")
853 .await
854 .unwrap();
855 assert_eq!(s.len(), 1);
856 assert!(s.last_committed_token("scope").await.unwrap().is_none());
857 }
858
859 #[tokio::test]
860 async fn lying_keyed_sink_appends_duplicates() {
861 let s = LyingKeyedSink::new();
862 assert!(s.is_empty());
863 assert!(s.dedups_by_key());
864 assert!(s.supported_write_modes().contains(&WriteMode::Upsert));
865 assert_eq!(s.connector_name(), "lying-keyed-sink");
866 s.write_batch(&[json!({ "id": 1 })]).await.unwrap();
867 s.write_batch(&[json!({ "id": 1 })]).await.unwrap();
868 assert_eq!(s.len(), 2, "lying keyed sink does not dedup");
869 }
870
871 #[tokio::test]
872 async fn failing_and_panicking_source_labels() {
873 assert_eq!(FailingSource.connector_name(), "failing-source");
874 assert_eq!(PanickingSource.connector_name(), "panicking-source");
875 assert!(FailingSource.fetch_all().await.is_err());
876 }
877
878 #[tokio::test]
879 async fn test_sink_delete_marker_removes_row() {
880 let s = TestSink::keyed_upsert("id");
881 assert!(s.supported_write_modes().contains(&WriteMode::Delete));
882 s.write_batch(&[json!({ "id": 1, "v": "a" })])
883 .await
884 .unwrap();
885 assert_eq!(s.len(), 1);
886 s.write_batch(&[json!({ "id": 1, "__op": "d" })])
888 .await
889 .unwrap();
890 assert_eq!(s.len(), 0, "delete marker must remove the row");
891 let plain = TestSink::keyed("id");
893 plain
894 .write_batch(&[json!({ "id": 2, "__op": "d" })])
895 .await
896 .unwrap();
897 assert_eq!(plain.len(), 1, "no marker configured → the row is upserted");
898 }
899
900 #[tokio::test]
901 async fn evolving_sink_evolves_and_noop_does_not() {
902 let evo = EvolvingSink::new();
903 assert_eq!(evo.connector_name(), "evolving-sink");
904 assert_eq!(evo.write_batch(&[json!({ "id": 1 })]).await.unwrap(), 1);
905 assert_eq!(evo.column_count(), 1);
906 let evolution = SchemaEvolution {
907 additions: vec![ColumnChange {
908 name: "email".to_string(),
909 from: None,
910 to: json!({ "type": "string" }),
911 }],
912 widenings: Vec::new(),
913 relax_nullability: Vec::new(),
914 };
915 evo.evolve_schema(&evolution).await.unwrap();
916 assert_eq!(evo.column_count(), 2);
917 let schema = evo.current_schema().await.unwrap().unwrap();
918 assert!(schema["properties"]["email"].is_object());
919
920 let noop = NoOpEvolvingSink;
921 assert!(noop.supports_schema_evolution());
922 assert_eq!(noop.write_batch(&[json!({ "id": 1 })]).await.unwrap(), 1);
923 let before = noop.current_schema().await.unwrap().unwrap();
924 noop.evolve_schema(&evolution).await.unwrap();
925 let after = noop.current_schema().await.unwrap().unwrap();
926 assert_eq!(before, after, "noop evolve must not change the schema");
927 }
928
929 #[tokio::test]
930 async fn multi_page_zero_source_emits_multiple_pages_and_fetches() {
931 let s = MultiPageZeroSource::new(6);
932 assert_eq!(s.connector_name(), "multi-page-zero-source");
933 let ctx: HashMap<String, Value> = HashMap::new();
934 assert_eq!(s.fetch_with_context(&ctx).await.unwrap().len(), 6);
935 let mut stream = s.stream_pages(&ctx, 0);
936 let mut pages = 0usize;
937 let mut records = 0usize;
938 while let Some(p) = stream.next().await {
939 let p = p.unwrap();
940 pages += 1;
941 records += p.records.len();
942 }
943 assert_eq!(records, 6);
944 assert!(pages > 1, "must emit more than one page under batch_size=0");
945 }
946
947 #[tokio::test]
948 async fn empty_name_and_erring_check_doubles() {
949 assert_eq!(EmptyNameSource.connector_name(), "");
950 assert!(
951 EmptyNameSource
952 .fetch_with_context(&HashMap::new())
953 .await
954 .unwrap()
955 .is_empty()
956 );
957
958 let ctx = CheckContext::default();
959 assert_eq!(ErringCheckSource.connector_name(), "erring-check-source");
960 assert!(
961 ErringCheckSource
962 .fetch_with_context(&HashMap::new())
963 .await
964 .unwrap()
965 .is_empty()
966 );
967 assert!(ErringCheckSource.check(&ctx).await.is_err());
968
969 let sink = ErringCheckSink;
970 assert_eq!(sink.connector_name(), "erring-check-sink");
971 assert_eq!(sink.write_batch(&[json!({ "x": 1 })]).await.unwrap(), 1);
972 assert!(sink.check(&ctx).await.is_err());
973 }
974
975 #[tokio::test]
976 async fn discoverable_source_enumerates_its_catalog() {
977 let s = DiscoverableSource::new();
978 assert_eq!(s.connector_name(), "discoverable-source");
979 assert!(s.supports_discover());
980 let ds = s.discover().await.unwrap();
981 assert_eq!(ds.len(), 2);
982 assert_eq!(ds[0].name, "orders");
983 assert_eq!(ds[0].config_patch, json!({ "dataset": "orders" }));
984 assert!(
987 s.fetch_with_context(&HashMap::new())
988 .await
989 .unwrap()
990 .is_empty()
991 );
992
993 let empty = DiscoverableSource::empty();
995 assert!(empty.supports_discover());
996 assert!(empty.discover().await.unwrap().is_empty());
997 }
998
999 #[tokio::test]
1000 async fn buffered_sink_only_durable_after_flush_unless_broken() {
1001 let s = BufferedSink::new();
1002 assert_eq!(s.connector_name(), "buffered-sink");
1003 s.write_batch(&[json!({ "id": 1 }), json!({ "id": 2 })])
1004 .await
1005 .unwrap();
1006 assert_eq!(s.staged_len(), 2);
1008 assert_eq!(s.durable_len(), 0);
1009 s.flush().await.unwrap();
1010 assert_eq!(s.staged_len(), 0);
1011 assert_eq!(s.durable_len(), 2, "flush must commit the staged rows");
1012
1013 let broken = BufferedSink::broken();
1015 broken.write_batch(&[json!({ "id": 1 })]).await.unwrap();
1016 broken.flush().await.unwrap();
1017 assert_eq!(broken.durable_len(), 0, "broken flush drops the buffer");
1018 }
1019}