use faucet_common_kafka::{KafkaAuth, KafkaValueFormat, OnDecodeError};
use faucet_core::check::{CheckContext, ProbeStatus};
use faucet_core::{DEFAULT_BATCH_SIZE, Source};
use faucet_source_kafka::{KafkaSource, KafkaSourceConfig, OffsetReset};
use rdkafka::ClientConfig;
use rdkafka::message::{Header, OwnedHeaders};
use rdkafka::producer::{FutureProducer, FutureRecord, Producer};
use std::collections::BTreeMap;
use std::time::Duration;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::kafka::apache::{KAFKA_PORT, Kafka};
async fn start_kafka() -> (testcontainers::ContainerAsync<Kafka>, String) {
let container = Kafka::default()
.start()
.await
.expect("kafka container start");
let port = container
.get_host_port_ipv4(KAFKA_PORT)
.await
.expect("kafka port");
(container, format!("127.0.0.1:{port}"))
}
async fn produce(brokers: &str, topic: &str, messages: &[(Option<&str>, &str)]) {
let producer: FutureProducer = ClientConfig::new()
.set("bootstrap.servers", brokers)
.set("message.timeout.ms", "5000")
.create()
.expect("producer init");
for (key, value) in messages {
let mut record: FutureRecord<'_, str, str> = FutureRecord::to(topic).payload(*value);
if let Some(k) = key {
record = record.key(*k);
}
producer
.send(record, Duration::from_secs(5))
.await
.expect("producer send");
}
producer
.flush(Duration::from_secs(5))
.expect("producer flush");
}
async fn produce_with_headers(brokers: &str, topic: &str, value: &str) {
let producer: FutureProducer = ClientConfig::new()
.set("bootstrap.servers", brokers)
.set("message.timeout.ms", "5000")
.create()
.expect("producer init");
let headers = OwnedHeaders::new()
.insert(Header {
key: "trace-id",
value: Some("abc-123".as_bytes()),
})
.insert(Header {
key: "blob",
value: Some(&[0xFFu8, 0x00, 0xFE][..]),
});
let record: FutureRecord<'_, str, str> =
FutureRecord::to(topic).payload(value).headers(headers);
producer
.send(record, Duration::from_secs(5))
.await
.expect("producer send");
producer
.flush(Duration::from_secs(5))
.expect("producer flush");
}
fn source_config(
brokers: &str,
topic: &str,
group: &str,
max_messages: usize,
) -> KafkaSourceConfig {
KafkaSourceConfig {
brokers: brokers.into(),
topics: vec![topic.into()],
group_id: group.into(),
auth: KafkaAuth::None,
value_format: KafkaValueFormat::Json,
key_format: None,
auto_offset_reset: OffsetReset::Earliest,
max_messages: Some(max_messages),
idle_timeout: Some(Duration::from_secs(30)),
poll_timeout: Duration::from_secs(1),
session_timeout: Duration::from_secs(30),
on_decode_error: OnDecodeError::Fail,
extra_client_config: BTreeMap::new(),
batch_size: DEFAULT_BATCH_SIZE,
}
}
#[tokio::test(flavor = "multi_thread")]
async fn key_format_json_decodes_key_as_json() {
let (_c, brokers) = start_kafka().await;
let topic = "cov-key-json";
produce(&brokers, topic, &[(Some(r#"{"k":1}"#), r#"{"id":1}"#)]).await;
let mut cfg = source_config(&brokers, topic, "g-cov-key-json", 1);
cfg.key_format = Some(KafkaValueFormat::Json);
let source = KafkaSource::new(cfg).await.unwrap();
let records = source.fetch_all().await.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0]["key"]["k"], 1);
assert_eq!(records[0]["value"]["id"], 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn missing_key_is_json_null() {
let (_c, brokers) = start_kafka().await;
let topic = "cov-no-key";
produce(&brokers, topic, &[(None, r#"{"id":9}"#)]).await;
let source = KafkaSource::new(source_config(&brokers, topic, "g-cov-no-key", 1))
.await
.unwrap();
let records = source.fetch_all().await.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0]["key"], serde_json::Value::Null);
}
#[tokio::test(flavor = "multi_thread")]
async fn message_headers_are_surfaced_utf8_and_base64() {
let (_c, brokers) = start_kafka().await;
let topic = "cov-headers";
produce_with_headers(&brokers, topic, r#"{"id":1}"#).await;
let source = KafkaSource::new(source_config(&brokers, topic, "g-cov-headers", 1))
.await
.unwrap();
let records = source.fetch_all().await.unwrap();
assert_eq!(records.len(), 1);
let headers = &records[0]["headers"];
assert_eq!(headers["trace-id"], "abc-123");
assert_eq!(headers["blob"], "/wD+");
}
#[tokio::test(flavor = "multi_thread")]
async fn on_decode_error_skip_drops_invalid_records() {
let (_c, brokers) = start_kafka().await;
let topic = "cov-skip";
produce(
&brokers,
topic,
&[
(None, r#"{"id":1}"#),
(None, "{not json"),
(None, r#"{"id":3}"#),
],
)
.await;
let mut cfg = source_config(&brokers, topic, "g-cov-skip", 100);
cfg.on_decode_error = OnDecodeError::Skip;
cfg.idle_timeout = Some(Duration::from_secs(10));
let source = KafkaSource::new(cfg).await.unwrap();
let records = source.fetch_all().await.unwrap();
assert_eq!(records.len(), 2, "the invalid record must be skipped");
assert_eq!(records[0]["value"]["id"], 1);
assert_eq!(records[1]["value"]["id"], 3);
}
#[tokio::test(flavor = "multi_thread")]
async fn on_decode_error_fail_surfaces_error() {
let (_c, brokers) = start_kafka().await;
let topic = "cov-fail";
produce(&brokers, topic, &[(None, "{still not json")]).await;
let cfg = source_config(&brokers, topic, "g-cov-fail", 1);
let source = KafkaSource::new(cfg).await.unwrap();
let err = source.fetch_all().await.unwrap_err();
let msg = format!("{err}").to_lowercase();
assert!(
msg.contains("json"),
"expected json decode error, got {msg}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn extra_client_config_is_applied() {
let (_c, brokers) = start_kafka().await;
let topic = "cov-extra-config";
produce(&brokers, topic, &[(None, r#"{"id":1}"#)]).await;
let mut cfg = source_config(&brokers, topic, "g-cov-extra", 1);
cfg.extra_client_config
.insert("fetch.min.bytes".into(), "1".into());
let source = KafkaSource::new(cfg).await.unwrap();
let records = source.fetch_all().await.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0]["value"]["id"], 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn check_passes_against_reachable_broker() {
let (_c, brokers) = start_kafka().await;
let source = KafkaSource::new(source_config(&brokers, "cov-check", "g-cov-check", 1))
.await
.unwrap();
let report = source.check(&CheckContext::default()).await.unwrap();
assert_eq!(report.probes.len(), 1);
assert_eq!(report.probes[0].name, "metadata");
assert!(
matches!(report.probes[0].status, ProbeStatus::Pass),
"expected a passing metadata probe, got {:?}",
report.probes[0].status
);
}
#[tokio::test(flavor = "multi_thread")]
async fn check_fails_against_unreachable_broker() {
let mut cfg = source_config("127.0.0.1:1", "cov-check-bad", "g-cov-check-bad", 1);
cfg.session_timeout = Duration::from_secs(6);
let source = KafkaSource::new(cfg).await.unwrap();
let ctx = CheckContext {
timeout: Duration::from_secs(3),
};
let report = source.check(&ctx).await.unwrap();
assert_eq!(report.probes.len(), 1);
assert_eq!(report.probes[0].name, "metadata");
assert!(
matches!(report.probes[0].status, ProbeStatus::Fail { .. }),
"expected a failing metadata probe, got {:?}",
report.probes[0].status
);
}
#[tokio::test(flavor = "multi_thread")]
async fn accessor_methods_on_constructed_source() {
let (_c, brokers) = start_kafka().await;
let source = KafkaSource::new(source_config(&brokers, "cov-accessors", "g-cov-acc", 1))
.await
.unwrap();
assert_eq!(source.connector_name(), "kafka");
let uri = source.dataset_uri();
assert!(
uri.starts_with("kafka://") && uri.ends_with("?topic=cov-accessors"),
"unexpected dataset_uri: {uri}"
);
let schema = source.config_schema();
assert!(schema.is_object());
assert!(
schema["properties"]["topics"].is_object(),
"config_schema must describe the topics field"
);
}