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 c849b21d [improve][test] Add Solr sink integration test (#75)
c849b21d is described below

commit c849b21d465404e3a53681e1f4ba1adb244d0786
Author: David Kjerrumgaard <[email protected]>
AuthorDate: Thu Jul 9 15:33:27 2026 -0700

    [improve][test] Add Solr sink integration test (#75)
    
    * [improve][test] Add Solr sink integration test
    
    Add a Testcontainers-based integration test for the Solr sink connector
    (SolrGenericRecordSink). The test starts a real Solr 9.8 container, writes
    GenericRecord documents through the sink, and reads them back via SolrJ to
    assert the documents and their field values landed in Solr.
    
    Fixes #57
    
    * 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]>
---
 solr/build.gradle.kts                              |   1 +
 .../solr/SolrGenericRecordSinkIntegrationTest.java | 189 +++++++++++++++++++++
 2 files changed, 190 insertions(+)

diff --git a/solr/build.gradle.kts b/solr/build.gradle.kts
index d338d67b..8215f6de 100644
--- a/solr/build.gradle.kts
+++ b/solr/build.gradle.kts
@@ -49,4 +49,5 @@ dependencies {
 
     testImplementation(libs.solr.test.framework)
     testImplementation(libs.solr.core)
+    testImplementation(libs.testcontainers)
 }
diff --git 
a/solr/src/test/java/org/apache/pulsar/io/solr/SolrGenericRecordSinkIntegrationTest.java
 
b/solr/src/test/java/org/apache/pulsar/io/solr/SolrGenericRecordSinkIntegrationTest.java
new file mode 100644
index 00000000..7f32e57a
--- /dev/null
+++ 
b/solr/src/test/java/org/apache/pulsar/io/solr/SolrGenericRecordSinkIntegrationTest.java
@@ -0,0 +1,189 @@
+/*
+ * 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.solr;
+
+import static org.awaitility.Awaitility.await;
+import static org.mockito.Mockito.mock;
+import static org.testng.Assert.assertEquals;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pulsar.client.api.schema.GenericRecord;
+import org.apache.pulsar.client.impl.schema.AvroSchema;
+import org.apache.pulsar.client.impl.schema.generic.GenericAvroSchema;
+import org.apache.pulsar.functions.api.Record;
+import org.apache.pulsar.io.core.SinkContext;
+import org.apache.solr.client.solrj.impl.HttpSolrClient;
+import org.apache.solr.client.solrj.response.QueryResponse;
+import org.apache.solr.common.SolrDocument;
+import org.apache.solr.common.params.MapSolrParams;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * Integration test for {@link SolrGenericRecordSink} that exercises the full 
write path against a
+ * real Solr instance running in a Testcontainers-managed Docker container. 
Records are pushed
+ * through the sink and then read back via SolrJ to assert the documents and 
their field values
+ * actually landed in Solr.
+ */
+@Slf4j
+public class SolrGenericRecordSinkIntegrationTest {
+
+    // Matches the repo's solr = "9.8.0" version catalog entry.
+    private static final DockerImageName SOLR_IMAGE = 
DockerImageName.parse("solr:9.8");
+    private static final int SOLR_PORT = 8983;
+    private static final String COLLECTION = "pulsar_test";
+
+    private GenericContainer<?> solrContainer;
+    private String baseSolrUrl;
+    private SolrGenericRecordSink sink;
+
+    /**
+     * A simple record POJO. The {@code id} field is required by Solr's 
default (schemaless)
+     * configset because it is the uniqueKey; the remaining fields are 
auto-added to the schema.
+     */
+    @Data
+    public static class Product {
+        private String id;
+        private String title;
+        private String category;
+    }
+
+    @BeforeMethod
+    public void setUp() {
+        // "solr-precreate <core>" creates the core before launching Solr in 
the foreground, so the
+        // core is guaranteed to exist by the time the HTTP endpoint becomes 
reachable.
+        solrContainer = new GenericContainer<>(SOLR_IMAGE)
+                .withExposedPorts(SOLR_PORT)
+                .withCommand("solr-precreate", COLLECTION)
+                
.waitingFor(Wait.forHttp("/solr/admin/cores?action=STATUS&core=" + COLLECTION)
+                        .forPort(SOLR_PORT)
+                        .forStatusCode(200)
+                        .withStartupTimeout(Duration.ofMinutes(3)));
+        solrContainer.start();
+
+        baseSolrUrl = "http://"; + solrContainer.getHost() + ":"
+                + solrContainer.getMappedPort(SOLR_PORT) + "/solr";
+        sink = new SolrGenericRecordSink();
+    }
+
+    @AfterMethod(alwaysRun = true)
+    public void tearDown() throws Exception {
+        if (sink != null) {
+            try {
+                sink.close();
+            } catch (Exception e) {
+                // ignore
+            }
+        }
+        if (solrContainer != null) {
+            solrContainer.stop();
+        }
+    }
+
+    @Test(timeOut = 300_000)
+    public void testWriteAndReadBack() throws Exception {
+        Map<String, Object> config = new HashMap<>();
+        config.put("solrUrl", baseSolrUrl);
+        config.put("solrMode", "Standalone");
+        config.put("solrCollection", COLLECTION);
+        config.put("solrCommitWithinMs", "100");
+
+        sink.open(config, mock(SinkContext.class));
+
+        AvroSchema<Product> schema = AvroSchema.of(Product.class);
+        GenericAvroSchema genericAvroSchema = new 
GenericAvroSchema(schema.getSchemaInfo());
+
+        int numRecords = 5;
+        CompletableFuture<?>[] acks = new CompletableFuture[numRecords];
+        for (int i = 0; i < numRecords; i++) {
+            Product product = new Product();
+            product.setId("doc-" + i);
+            product.setTitle("title-" + i);
+            product.setCategory("category-" + i);
+
+            acks[i] = new CompletableFuture<>();
+            final int idx = i;
+            GenericRecord value = 
genericAvroSchema.decode(schema.encode(product));
+            Record<GenericRecord> record = new Record<GenericRecord>() {
+                @Override
+                public GenericRecord getValue() {
+                    return value;
+                }
+
+                @Override
+                public void ack() {
+                    acks[idx].complete(null);
+                }
+
+                @Override
+                public void fail() {
+                    acks[idx].completeExceptionally(new 
RuntimeException("Record " + idx + " failed"));
+                }
+            };
+            sink.write(record);
+        }
+
+        // All records must be acknowledged by the sink (status 0 from Solr).
+        CompletableFuture.allOf(acks).get();
+
+        // The sink commits asynchronously (commitWithin=100ms), so poll until 
the documents become
+        // searchable rather than sleeping for a fixed interval.
+        try (HttpSolrClient queryClient = new 
HttpSolrClient.Builder(baseSolrUrl).build()) {
+            await().atMost(60, TimeUnit.SECONDS).untilAsserted(() -> {
+                Map<String, String> params = new HashMap<>();
+                params.put("q", "*:*");
+                QueryResponse response = queryClient.query(COLLECTION, new 
MapSolrParams(params));
+                assertEquals(response.getResults().getNumFound(), numRecords,
+                        "expected all records to be indexed and searchable");
+            });
+
+            // Verify each document's field values were persisted correctly.
+            for (int i = 0; i < numRecords; i++) {
+                Map<String, String> params = new HashMap<>();
+                params.put("q", "id:doc-" + i);
+                QueryResponse response = queryClient.query(COLLECTION, new 
MapSolrParams(params));
+                assertEquals(response.getResults().getNumFound(), 1L, 
"expected exactly one doc for id doc-" + i);
+                SolrDocument doc = response.getResults().get(0);
+                assertEquals(doc.getFieldValue("id"), "doc-" + i);
+                assertEquals(firstValue(doc, "title"), "title-" + i);
+                assertEquals(firstValue(doc, "category"), "category-" + i);
+            }
+        }
+    }
+
+    // Schemaless string fields may be stored as single-valued or multi-valued 
depending on the
+    // field-guessing update processor; normalize to the first value for 
assertions.
+    private static Object firstValue(SolrDocument doc, String field) {
+        Object v = doc.getFieldValue(field);
+        if (v instanceof java.util.Collection) {
+            java.util.Collection<?> c = (java.util.Collection<?>) v;
+            return c.isEmpty() ? null : c.iterator().next();
+        }
+        return v;
+    }
+}

Reply via email to