Copilot commented on code in PR #76:
URL: https://github.com/apache/pulsar-connectors/pull/76#discussion_r3555127889


##########
kafka/src/test/java/org/apache/pulsar/io/kafka/KafkaConnectorIntegrationTest.java:
##########
@@ -0,0 +1,322 @@
+/*
+ * 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(new String(actual.get(entry.getKey()), 
StandardCharsets.UTF_8),
+                    new String(entry.getValue(), StandardCharsets.UTF_8),
+                    "payload mismatch for key " + entry.getKey());

Review Comment:
   This test is meant to validate byte-for-byte delivery. Converting payloads 
to UTF-8 strings weakens the assertion (it can mask differences for 
non-text/binary payloads). Compare the raw byte arrays instead.



##########
kafka/src/test/java/org/apache/pulsar/io/kafka/KafkaConnectorIntegrationTest.java:
##########
@@ -0,0 +1,322 @@
+/*
+ * 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(new String(actual.get(entry.getKey()), 
StandardCharsets.UTF_8),
+                    new String(entry.getValue(), StandardCharsets.UTF_8),
+                    "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;
+        }
+    }

Review Comment:
   `readOne()` returns null on timeout, which makes failures slower and less 
diagnosable (callers may keep looping until an outer timeout). Prefer failing 
immediately with a clear AssertionError once the per-record deadline is 
exceeded.



##########
kafka/src/test/java/org/apache/pulsar/io/kafka/KafkaConnectorIntegrationTest.java:
##########
@@ -0,0 +1,322 @@
+/*
+ * 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(new String(actual.get(entry.getKey()), 
StandardCharsets.UTF_8),
+                    new String(entry.getValue(), StandardCharsets.UTF_8),
+                    "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");
+                    });

Review Comment:
   The source-side test currently uses Awaitility + a `readOne()` that returns 
null on timeout, so a broken connector can take the full Awaitility window 
(120s) to fail and the failure message is less actionable. Since the expected 
record count is known, read exactly `expected.size()` records with a per-record 
timeout to fail fast and deterministically.



##########
kafka/src/test/java/org/apache/pulsar/io/kafka/KafkaConnectorIntegrationTest.java:
##########
@@ -0,0 +1,322 @@
+/*
+ * 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(new String(actual.get(entry.getKey()), 
StandardCharsets.UTF_8),
+                    new String(entry.getValue(), StandardCharsets.UTF_8),
+                    "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());

Review Comment:
   This test is meant to validate byte-for-byte delivery. Converting payloads 
to UTF-8 strings weakens the assertion (it can mask differences for 
non-text/binary payloads). Compare the raw byte arrays instead.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


Reply via email to