This is an automated email from the ASF dual-hosted git repository.

david-streamlio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar-connectors.git


The following commit(s) were added to refs/heads/master by this push:
     new 02261c44 [improve][test] Add Kafka source and sink integration tests 
(#76)
02261c44 is described below

commit 02261c44eed4f7447b7a057a9e120e69fa5252c3
Author: David Kjerrumgaard <[email protected]>
AuthorDate: Thu Jul 9 16:18:36 2026 -0700

    [improve][test] Add Kafka source and sink integration tests (#76)
    
    * [improve][test] Add Kafka source and sink integration tests
    
    Adds a Testcontainers-based integration test that exercises both the Kafka
    source and sink connectors against a real Kafka broker, complementing the
    existing mock-only unit tests in the module.
    
    Fixes #54
    
    * Potential fix for pull request finding
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
    
    ---------
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 kafka/build.gradle.kts                             |   2 +
 .../io/kafka/KafkaConnectorIntegrationTest.java    | 321 +++++++++++++++++++++
 2 files changed, 323 insertions(+)

diff --git a/kafka/build.gradle.kts b/kafka/build.gradle.kts
index 6d219d04..533d5db8 100644
--- a/kafka/build.gradle.kts
+++ b/kafka/build.gradle.kts
@@ -48,4 +48,6 @@ dependencies {
     testImplementation(libs.hamcrest)
     testImplementation(libs.awaitility)
     testImplementation(libs.bcpkix.jdk18on)
+    testImplementation(libs.testcontainers)
+    testImplementation(libs.testcontainers.kafka)
 }
diff --git 
a/kafka/src/test/java/org/apache/pulsar/io/kafka/KafkaConnectorIntegrationTest.java
 
b/kafka/src/test/java/org/apache/pulsar/io/kafka/KafkaConnectorIntegrationTest.java
new file mode 100644
index 00000000..7eb48fda
--- /dev/null
+++ 
b/kafka/src/test/java/org/apache/pulsar/io/kafka/KafkaConnectorIntegrationTest.java
@@ -0,0 +1,321 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pulsar.io.kafka;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.ConsumerRecords;
+import org.apache.kafka.clients.consumer.KafkaConsumer;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.Producer;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.serialization.ByteArrayDeserializer;
+import org.apache.kafka.common.serialization.ByteArraySerializer;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.apache.pulsar.functions.api.Record;
+import org.apache.pulsar.io.core.SinkContext;
+import org.apache.pulsar.io.core.SourceContext;
+import org.awaitility.Awaitility;
+import org.mockito.Mockito;
+import org.testcontainers.kafka.KafkaContainer;
+import org.testcontainers.utility.DockerImageName;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Integration tests for the Kafka source and sink connectors, exercised 
against a real Kafka
+ * broker via Testcontainers.
+ *
+ * <p>Unlike the other Kafka unit tests in this module (which mock the 
broker), these tests wire
+ * the connectors up to an actual {@link KafkaContainer} and verify end-to-end 
delivery:
+ * <ul>
+ *   <li><b>Sink</b>: records are written through {@link KafkaBytesSink} and 
then read back from
+ *       the topic with a plain {@link KafkaConsumer}; keys and payloads are 
asserted.</li>
+ *   <li><b>Source</b>: messages are produced to a topic with a plain {@link 
KafkaProducer} and
+ *       then {@link KafkaBytesSource#read()} is asserted to yield them.</li>
+ * </ul>
+ *
+ * <p>{@link KafkaBytesSource} extends {@code PushSource}, whose {@code 
read()} blocks on an
+ * internal queue and never returns when the topic is drained. Every {@code 
read()} is therefore
+ * performed on a bounded worker thread with a per-record deadline (see {@link 
#readOne}) so the
+ * test can fail fast instead of hanging.
+ */
+@Slf4j
+public class KafkaConnectorIntegrationTest {
+
+    private static final DockerImageName KAFKA_IMAGE = 
DockerImageName.parse("apache/kafka:3.9.1");
+
+    private KafkaContainer kafka;
+    private ExecutorService readerExecutor;
+
+    @BeforeClass(alwaysRun = true)
+    public void setUp() {
+        kafka = new KafkaContainer(KAFKA_IMAGE);
+        kafka.start();
+        readerExecutor = Executors.newSingleThreadExecutor();
+    }
+
+    @AfterClass(alwaysRun = true)
+    public void tearDown() {
+        if (readerExecutor != null) {
+            readerExecutor.shutdownNow();
+        }
+        if (kafka != null) {
+            kafka.stop();
+        }
+    }
+
+    /**
+     * Writes records through the Kafka sink connector and verifies that a 
plain KafkaConsumer can
+     * read the exact keys and payloads back from the topic.
+     */
+    @Test(timeOut = 300_000)
+    public void testSinkWritesToKafka() throws Exception {
+        String topic = "sink-it-" + UUID.randomUUID();
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("bootstrapServers", kafka.getBootstrapServers());
+        config.put("topic", topic);
+        config.put("acks", "all");
+        config.put("batchSize", 1);
+
+        // key -> payload the sink should deliver to Kafka, in insertion order.
+        Map<String, byte[]> expected = new LinkedHashMap<>();
+        expected.put("k0", "sink-value-0".getBytes(StandardCharsets.UTF_8));
+        expected.put("k1", "sink-value-1".getBytes(StandardCharsets.UTF_8));
+        expected.put("k2", "sink-value-2".getBytes(StandardCharsets.UTF_8));
+
+        KafkaBytesSink sink = new KafkaBytesSink();
+        List<CompletableFuture<Boolean>> acks = new ArrayList<>();
+        try {
+            sink.open(config, Mockito.mock(SinkContext.class));
+
+            for (Map.Entry<String, byte[]> entry : expected.entrySet()) {
+                CompletableFuture<Boolean> ackFuture = new 
CompletableFuture<>();
+                acks.add(ackFuture);
+                sink.write(new MockRecord(entry.getKey(), entry.getValue(), 
ackFuture));
+            }
+
+            // Every record must be acked by the sink's producer callback.
+            for (CompletableFuture<Boolean> ack : acks) {
+                assertTrue(ack.get(60, TimeUnit.SECONDS), "sink should have 
acked the record");
+            }
+        } finally {
+            sink.close();
+        }
+
+        // Read the topic back with a vanilla consumer and assert key + 
payload.
+        Map<String, byte[]> actual = consumeAll(topic, expected.size());
+        assertEquals(actual.size(), expected.size(), "unexpected number of 
records in Kafka topic");
+        for (Map.Entry<String, byte[]> entry : expected.entrySet()) {
+            assertTrue(actual.containsKey(entry.getKey()), "missing key " + 
entry.getKey());
+            assertEquals(actual.get(entry.getKey()), entry.getValue(),
+                    "payload mismatch for key " + entry.getKey());
+        }
+    }
+
+    /**
+     * Produces messages to a Kafka topic with a plain KafkaProducer and 
verifies that the Kafka
+     * source connector reads back the exact keys and payloads.
+     */
+    @Test(timeOut = 300_000)
+    public void testSourceReadsFromKafka() throws Exception {
+        String topic = "source-it-" + UUID.randomUUID();
+
+        Map<String, byte[]> expected = new LinkedHashMap<>();
+        expected.put("sk0", "source-value-0".getBytes(StandardCharsets.UTF_8));
+        expected.put("sk1", "source-value-1".getBytes(StandardCharsets.UTF_8));
+        expected.put("sk2", "source-value-2".getBytes(StandardCharsets.UTF_8));
+
+        produce(topic, expected);
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("bootstrapServers", kafka.getBootstrapServers());
+        config.put("topic", topic);
+        config.put("groupId", "source-it-group-" + UUID.randomUUID());
+        config.put("autoOffsetReset", "earliest");
+
+        KafkaBytesSource source = new KafkaBytesSource();
+        Map<String, byte[]> actual = new HashMap<>();
+        try {
+            source.open(config, Mockito.mock(SourceContext.class));
+
+            // Collect records off the source's queue with a per-record 
deadline until we have
+            // them all, bounded overall by Awaitility.
+            Awaitility.await()
+                    .atMost(120, TimeUnit.SECONDS)
+                    .pollInterval(200, TimeUnit.MILLISECONDS)
+                    .untilAsserted(() -> {
+                        Record<ByteBuffer> record = readOne(source, 2000);
+                        if (record != null) {
+                            assertNotNull(record.getValue(), "source record 
value should not be null");
+                            String key = record.getKey().orElse(null);
+                            assertNotNull(key, "source record key should not 
be null");
+                            actual.put(key, toArray(record.getValue()));
+                            record.ack();
+                        }
+                        assertEquals(actual.size(), expected.size(),
+                                "did not yet read all records from the 
source");
+                    });
+        } finally {
+            source.close();
+        }
+
+        for (Map.Entry<String, byte[]> entry : expected.entrySet()) {
+            assertTrue(actual.containsKey(entry.getKey()), "missing key " + 
entry.getKey());
+            assertEquals(new String(actual.get(entry.getKey()), 
StandardCharsets.UTF_8),
+                    new String(entry.getValue(), StandardCharsets.UTF_8),
+                    "payload mismatch for key " + entry.getKey());
+        }
+    }
+
+    /**
+     * Reads a single record from the source on a bounded worker thread. 
{@code PushSource.read()}
+     * blocks forever once the queue is drained, so we impose a per-record 
deadline and return
+     * {@code null} (interrupting the blocked read) when no record arrives in 
time.
+     */
+    private Record<ByteBuffer> readOne(KafkaBytesSource source, long 
timeoutMs) throws Exception {
+        Future<Record<ByteBuffer>> future = 
readerExecutor.submit(source::read);
+        try {
+            return future.get(timeoutMs, TimeUnit.MILLISECONDS);
+        } catch (TimeoutException e) {
+            future.cancel(true);
+            return null;
+        }
+    }
+
+    private Map<String, byte[]> consumeAll(String topic, int expectedCount) {
+        Properties props = new Properties();
+        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, 
kafka.getBootstrapServers());
+        props.put(ConsumerConfig.GROUP_ID_CONFIG, "verify-" + 
UUID.randomUUID());
+        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class.getName());
+        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 
ByteArrayDeserializer.class.getName());
+
+        Map<String, byte[]> collected = new HashMap<>();
+        try (KafkaConsumer<String, byte[]> consumer = new 
KafkaConsumer<>(props)) {
+            consumer.subscribe(Collections.singletonList(topic));
+            Awaitility.await()
+                    .atMost(60, TimeUnit.SECONDS)
+                    .pollInterval(200, TimeUnit.MILLISECONDS)
+                    .untilAsserted(() -> {
+                        ConsumerRecords<String, byte[]> records = 
consumer.poll(Duration.ofMillis(500));
+                        for (ConsumerRecord<String, byte[]> record : records) {
+                            // The header hook is a natural place to assert 
Pulsar-property ->
+                            // Kafka-header propagation once PR #36 lands.
+                            collected.put(record.key(), record.value());
+                        }
+                        assertEquals(collected.size(), expectedCount,
+                                "did not yet consume all records from the 
topic");
+                    });
+        }
+        return collected;
+    }
+
+    private void produce(String topic, Map<String, byte[]> records) {
+        Properties props = new Properties();
+        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, 
kafka.getBootstrapServers());
+        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName());
+        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
ByteArraySerializer.class.getName());
+        props.put(ProducerConfig.ACKS_CONFIG, "all");
+        try (Producer<String, byte[]> producer = new KafkaProducer<>(props)) {
+            for (Map.Entry<String, byte[]> entry : records.entrySet()) {
+                producer.send(new ProducerRecord<>(topic, entry.getKey(), 
entry.getValue()));
+            }
+            producer.flush();
+        }
+    }
+
+    private static byte[] toArray(ByteBuffer buffer) {
+        byte[] result = new byte[buffer.remaining()];
+        buffer.mark();
+        buffer.get(result);
+        buffer.reset();
+        return result;
+    }
+
+    /**
+     * Minimal {@link Record} of raw bytes for driving the sink. Carries a 
key, a payload and a
+     * properties map (kept for a future header-propagation assertion, see PR 
#36), and completes
+     * a future on ack/fail so the test can wait for the producer callback.
+     */
+    private static final class MockRecord implements Record<byte[]> {
+        private final String key;
+        private final byte[] value;
+        private final Map<String, String> properties;
+        private final CompletableFuture<Boolean> ackFuture;
+
+        private MockRecord(String key, byte[] value, 
CompletableFuture<Boolean> ackFuture) {
+            this.key = key;
+            this.value = value;
+            this.properties = new HashMap<>();
+            this.ackFuture = ackFuture;
+        }
+
+        @Override
+        public Optional<String> getKey() {
+            return Optional.ofNullable(key);
+        }
+
+        @Override
+        public byte[] getValue() {
+            return value;
+        }
+
+        @Override
+        public Map<String, String> getProperties() {
+            return properties;
+        }
+
+        @Override
+        public void ack() {
+            ackFuture.complete(true);
+        }
+
+        @Override
+        public void fail() {
+            ackFuture.complete(false);
+        }
+    }
+}

Reply via email to