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

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 2e60ed34cb issue #7507 : fix OpenSearch, MinIO, and samples 
integration test failures (#7508)
2e60ed34cb is described below

commit 2e60ed34cb4d2689ac8f5abd8b020b9e6c66f478
Author: Matt Casters <[email protected]>
AuthorDate: Sun Jul 12 16:20:26 2026 +0200

    issue #7507 : fix OpenSearch, MinIO, and samples integration test failures 
(#7508)
    
    Null-safe startDateFilter for OpenSearch/Neo4j execution info locations.
    REST response JsonPath works with FORM URLENCODED requests (Slack sample).
    Ignore Slack sample in CI without SLACK_BOT_TOKEN.
    Rewrite MinIO VFS writes to buffer-then-upload with known size and fix
    createFile/getOutputStream lifecycle so files are no longer empty.
    Add CLIENT_UNZIP option to run-tests-docker.sh (default true) so a patched
    target/hop can be reused with CLIENT_UNZIP=false.
    Fix .dockerignore so the Hop client install is included in docker builds.
    
    Local minio integration tests pass after these changes; confirm on Jenkins.
---
 .dockerignore                                      |  86 ++++++++----
 .../samples/read-samples-build-hop-run.hpl         |   4 +
 integration-tests/scripts/run-tests-docker.sh      |  23 ++-
 .../org/apache/hop/vfs/minio/MinioFileObject.java  |  25 ++--
 .../org/apache/hop/vfs/minio/MinioFileSystem.java  |   8 +-
 .../hop/vfs/minio/util/MinioPipedOutputStream.java | 132 ++++++++----------
 .../vfs/minio/util/MinioPipedOutputStreamTest.java | 155 ++++++++++++++-------
 .../neo4j/execution/NeoExecutionInfoLocation.java  |   5 +-
 .../OpenSearchExecutionInfoLocation.java           |   5 +-
 .../apache/hop/pipeline/transforms/rest/Rest.java  |  43 ++++--
 .../transforms/rest/ResponseJsonParseTest.java     |  82 +++++++++++
 11 files changed, 383 insertions(+), 185 deletions(-)

diff --git a/.dockerignore b/.dockerignore
index 71f30a9ef7..ada15024a7 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,36 +1,62 @@
-# Ignore everything
+# Ignore everything by default, then re-include only what Dockerfiles need.
+# With a leading "*", every parent path of a re-included file must also be 
re-included,
+# and directory contents need an explicit "/**" rule or they stay ignored.
 *
 
-# Allow files and directories needed for OLD Dockerfiles (pre-built artifacts)
-!/assemblies/client/target/hop-*
-!/assemblies/client/target/hop
-!/integration-tests/scripts
-!/integration-tests/ssh/keys
-!/assemblies/web/target
-!/assemblies/plugins/target
-!/docker
-!/rest
+# ---- Pre-built Hop client used by unit-tests.Dockerfile ----
+!assemblies/
+!assemblies/client/
+!assemblies/client/target/
+!assemblies/client/target/hop/
+!assemblies/client/target/hop/**
+!assemblies/client/target/hop-*
+
+# ---- Integration-test resources ----
+!integration-tests/
+!integration-tests/scripts/
+!integration-tests/scripts/**
+!integration-tests/ssh/
+!integration-tests/ssh/keys/
+!integration-tests/ssh/keys/**
+
+# ---- Other assembly artefacts ----
+!assemblies/web/
+!assemblies/web/target/
+!assemblies/web/target/**
+!assemblies/plugins/
+!assemblies/plugins/target/
+!assemblies/plugins/target/**
+
+# ---- Docker build context ----
+!docker/
+!docker/**
+
+# ---- REST / misc ----
+!rest/
+!rest/**
 !google-key-apache-hop-it.json
 
-# Allow source files needed for NEW unified Dockerfile (maven build in Docker)
+# ---- Source tree for unified Dockerfile (maven build inside Docker) ----
 !pom.xml
-!/*/pom.xml
-!/*/*/pom.xml
-!/*/*/*/pom.xml
-!/src
-!/*/src
-!/*/*/src
-!/*/*/*/src
-!/core
-!/engine
-!/engine-beam
-!/plugins
-!/assemblies
-!/ui
-!/rap
-!/rcp
-!/lib
-!/lib-jdbc
-!/.mvn
 !mvnw
-!mvnw.cmd
\ No newline at end of file
+!mvnw.cmd
+!.mvn/
+!.mvn/**
+!core/
+!core/**
+!engine/
+!engine/**
+!engine-beam/
+!engine-beam/**
+!plugins/
+!plugins/**
+!ui/
+!ui/**
+!rap/
+!rap/**
+!rcp/
+!rcp/**
+!lib/
+!lib/**
+!lib-jdbc/
+!lib-jdbc/**
diff --git a/integration-tests/samples/read-samples-build-hop-run.hpl 
b/integration-tests/samples/read-samples-build-hop-run.hpl
index 03643068f5..4397cc9e31 100644
--- a/integration-tests/samples/read-samples-build-hop-run.hpl
+++ b/integration-tests/samples/read-samples-build-hop-run.hpl
@@ -555,6 +555,10 @@ var hop_run_cmd = './hop-run.sh -j samples -r ' + 
run_config + ' -f ' + filename
         <item>table-compare.hpl</item>
         <item>requires connection</item>
       </line>
+      <line>
+        <item>rest-client-slack-conversations-loop.hpl</item>
+        <item>requires SLACK_BOT_TOKEN with channels:read or 
conversations:read</item>
+      </line>
     </data>
     <distribute>Y</distribute>
     <copies>1</copies>
diff --git a/integration-tests/scripts/run-tests-docker.sh 
b/integration-tests/scripts/run-tests-docker.sh
index 446a91e285..07787d9630 100755
--- a/integration-tests/scripts/run-tests-docker.sh
+++ b/integration-tests/scripts/run-tests-docker.sh
@@ -35,6 +35,7 @@ for ARGUMENT in "$@"; do
   JENKINS_GID) JENKINS_GID=${VALUE} ;;
   GCP_KEY_FILE) GCP_KEY_FILE=${VALUE} ;;
   KEEP_IMAGES) KEEP_IMAGES=${VALUE} ;;
+  CLIENT_UNZIP) CLIENT_UNZIP=${VALUE} ;;
   *) ;;
   esac
 
@@ -76,13 +77,31 @@ if [ -z "${KEEP_IMAGES}" ]; then
   KEEP_IMAGES="false"
 fi
 
+# Unzip the client zip into assemblies/client/target/hop by default. Set 
CLIENT_UNZIP=false to
+# skip when target/hop already exists (e.g. after patching a single plugin 
jar).
+if [ -z "${CLIENT_UNZIP}" ]; then
+  CLIENT_UNZIP="true"
+fi
+
 # Cleanup surefire reports
 rm -rf "${CURRENT_DIR}"/../surefire-reports
 mkdir -p "${CURRENT_DIR}"/../surefire-reports/
 chmod 777 "${CURRENT_DIR}"/../surefire-reports/
 
-# Unzip Hop
-unzip -o -q "${CURRENT_DIR}/../../assemblies/client/target/*.zip" -d 
${CURRENT_DIR}/../../assemblies/client/target/
+HOP_CLIENT_TARGET_DIR="${CURRENT_DIR}/../../assemblies/client/target"
+HOP_DIR="${HOP_CLIENT_TARGET_DIR}/hop"
+
+# Unzip Hop client unless skipped and a usable hop folder is already present
+if [ "${CLIENT_UNZIP}" = "true" ] || [ ! -d "${HOP_DIR}" ]; then
+  if [ "${CLIENT_UNZIP}" != "true" ] && [ ! -d "${HOP_DIR}" ]; then
+    echo "CLIENT_UNZIP=${CLIENT_UNZIP} but ${HOP_DIR} does not exist; 
unzipping client zip"
+  else
+    echo "Unzipping Hop client into ${HOP_CLIENT_TARGET_DIR} 
(CLIENT_UNZIP=${CLIENT_UNZIP})"
+  fi
+  unzip -o -q "${HOP_CLIENT_TARGET_DIR}"/*.zip -d "${HOP_CLIENT_TARGET_DIR}/"
+else
+  echo "Skipping client unzip (CLIENT_UNZIP=${CLIENT_UNZIP}, using existing 
${HOP_DIR})"
+fi
 
 # Build base image only once
 docker compose -f ${DOCKER_FILES_DIR}/integration-tests-base.yaml build 
--build-arg JENKINS_USER=${JENKINS_USER} --build-arg JENKINS_UID=${JENKINS_UID} 
--build-arg JENKINS_GROUP=${JENKINS_GROUP} --build-arg 
JENKINS_GID=${JENKINS_GID} --build-arg GCP_KEY_FILE=${GCP_KEY_FILE}
diff --git 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioFileObject.java
 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioFileObject.java
index 2496a728b6..df806d1a90 100644
--- 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioFileObject.java
+++ 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioFileObject.java
@@ -370,12 +370,20 @@ public class MinioFileObject extends 
AbstractFileObject<MinioFileSystem> {
   @Override
   public void createFile() throws FileSystemException {
     try {
-      OutputStream os = doGetOutputStream(false);
-
-      // Open and close it to create an empty file.
-      os.close();
+      // Create an empty object directly. Do not go through 
doGetOutputStream(): HopVfs always
+      // calls createFile() then getOutputStream(), and reusing a stream that 
createFile already
+      // closed left empty objects and "already closed" errors on the real 
write path.
+      PutObjectArgs args =
+          PutObjectArgs.builder()
+              .bucket(bucketName)
+              .object(key)
+              .contentType("application/octet-stream")
+              .stream(new ByteArrayInputStream(new byte[0]), 0, -1)
+              .build();
+      fileSystem.getClient().putObject(args);
+      fileSystem.invalidateListCacheForParentOf(bucketName, key);
+      // Force re-attach so subsequent size/type reads are not stuck on 
IMAGINARY.
       closeMinio();
-      // Update the metadata
       doAttach();
     } catch (Exception e) {
       throw new FileSystemException("error creating file", e);
@@ -384,11 +392,8 @@ public class MinioFileObject extends 
AbstractFileObject<MinioFileSystem> {
 
   @Override
   public OutputStream doGetOutputStream(boolean bAppend) throws Exception {
-    if (outputStream != null) {
-      // createFile() already built the output stream.
-      return outputStream;
-    }
-
+    // Always hand out a fresh stream. Do not close any previous stream here: 
HopVfs may still be
+    // wrapping it, and closing early left empty objects after 
createFile()+getOutputStream().
     outputStream = new MinioPipedOutputStream(fileSystem, bucketName, key);
     return outputStream;
   }
diff --git 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioFileSystem.java
 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioFileSystem.java
index 26d33b12bd..a96999daef 100644
--- 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioFileSystem.java
+++ 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/MinioFileSystem.java
@@ -140,21 +140,21 @@ public class MinioFileSystem extends AbstractFileSystem {
     this.listCacheTtlMs = listCacheTtlMs;
   }
 
-  void putListCache(
+  public void putListCache(
       String bucket, String prefix, java.util.Map<String, 
MinioListCache.ChildInfo> entries) {
     getListCache().put(bucket, prefix, entries);
   }
 
-  MinioListCache.ChildInfo getFromListCache(
+  public MinioListCache.ChildInfo getFromListCache(
       String bucket, String parentPrefix, String childFullKey) {
     return getListCache().get(bucket, parentPrefix, childFullKey);
   }
 
-  void invalidateListCache(String bucket, String prefix) {
+  public void invalidateListCache(String bucket, String prefix) {
     getListCache().invalidate(bucket, prefix);
   }
 
-  void invalidateListCacheForParentOf(String bucket, String key) {
+  public void invalidateListCacheForParentOf(String bucket, String key) {
     getListCache().invalidateParentOf(bucket, key);
   }
 }
diff --git 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/util/MinioPipedOutputStream.java
 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/util/MinioPipedOutputStream.java
index 5666669c65..44a65351d0 100644
--- 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/util/MinioPipedOutputStream.java
+++ 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/util/MinioPipedOutputStream.java
@@ -18,112 +18,96 @@
 package org.apache.hop.vfs.minio.util;
 
 import io.minio.PutObjectArgs;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.io.InterruptedIOException;
-import java.io.PipedInputStream;
-import java.io.PipedOutputStream;
+import java.io.OutputStream;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.vfs.minio.MinioFileSystem;
 
-/** Custom OutputStream that enables an output stream onto Minio */
+/**
+ * OutputStream that buffers content and uploads it to MinIO on {@link 
#close()}.
+ *
+ * <p>Previously this used a {@link java.io.PipedOutputStream} with the MinIO 
SDK's unknown-size
+ * ({@code objectSize = -1}) multipart path. That combination races with 
VFS/Text File Output
+ * close/flush chains and produced empty objects ({@code Pipe closed} on 
write). Buffering and
+ * uploading with a known content length matches the reliable path used for 
folder markers.
+ *
+ * <p>Close is idempotent and post-close flush is a no-op so nested VFS/Text 
File Output close
+ * chains (which flush after closing the inner stream) do not fail the 
pipeline.
+ */
 @Getter
 @Setter
-public class MinioPipedOutputStream extends PipedOutputStream {
+public class MinioPipedOutputStream extends OutputStream {
 
-  private boolean initialized = false;
-  private boolean blockedUntilDone = true;
-  private final PipedInputStream pipedInputStream;
-  private final MinioAsyncTransferRunner asyncTransferRunner;
+  private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(64 * 
1024);
   private final MinioFileSystem fileSystem;
-
-  // Daemon-threaded so a wedged putObject (black-holed socket) can never pin 
the JVM, and the
-  // uploader is referenced so close() can join() it interruptibly instead of 
polling forever.
-  private Thread transferThread;
-  private volatile Exception transferError;
-
   private final String bucketName;
   private final String key;
 
-  public MinioPipedOutputStream(MinioFileSystem fileSystem, String bucketName, 
String key)
-      throws IOException {
-    this.pipedInputStream = new PipedInputStream(100000);
-    try {
-      this.pipedInputStream.connect(this);
-    } catch (IOException e) {
-      throw new IOException("A MinIO piped output stream could not connect to 
the input stream", e);
-    }
+  private boolean closed;
+  private boolean blockedUntilDone = true;
 
-    this.asyncTransferRunner = new MinioAsyncTransferRunner();
+  public MinioPipedOutputStream(MinioFileSystem fileSystem, String bucketName, 
String key) {
+    this.fileSystem = fileSystem;
     this.bucketName = bucketName;
     this.key = key;
-    this.fileSystem = fileSystem;
-  }
-
-  private void initializeWrite() {
-    if (!initialized) {
-      initialized = true;
-      transferThread = new Thread(asyncTransferRunner, "minio-upload-" + 
bucketName + "-" + key);
-      transferThread.setDaemon(true);
-      transferThread.start();
-    }
   }
 
   @Override
   public void write(int b) throws IOException {
-    initializeWrite();
-    super.write(b);
+    // Outer VFS/Text File Output close chains may flush after the first 
close. Accept late bytes
+    // and re-open so a subsequent close() can upload the complete content.
+    if (closed) {
+      closed = false;
+    }
+    buffer.write(b);
   }
 
   @Override
   public void write(byte[] b, int off, int len) throws IOException {
-    initializeWrite();
-    super.write(b, off, len);
+    if (len == 0) {
+      return;
+    }
+    if (closed) {
+      closed = false;
+    }
+    buffer.write(b, off, len);
   }
 
   @Override
-  public synchronized void flush() {
-    // no flush
+  public void flush() {
+    // Data is held in memory until close(); nothing to flush to the network.
+    // No-op when closed so VFS MonitorOutputStream / FilterOutputStream 
double-close is safe.
   }
 
   @Override
   public void close() throws IOException {
-    // Start upload even when no bytes were written (0-byte file), so 
PutObject is still executed
-    initializeWrite();
-    super.close();
-    if (initialized && isBlockedUntilDone()) {
-      try {
-        // join() is interruptible: a pipeline stop interrupts the transform 
thread and now
-        // breaks out of here instead of looping forever on a stalled upload.
-        transferThread.join();
-      } catch (InterruptedException e) {
-        Thread.currentThread().interrupt();
-        transferThread.interrupt();
-        throw new InterruptedIOException(
-            "Interrupted while finishing the MinIO upload for " + bucketName + 
"/" + key);
-      }
-      if (transferError != null) {
-        throw new IOException("MinIO upload failed for " + bucketName + "/" + 
key, transferError);
-      }
+    if (closed) {
+      return;
     }
-  }
+    closed = true;
 
-  class MinioAsyncTransferRunner implements Runnable {
-    @Override
-    public void run() {
-      try {
-        PutObjectArgs args =
-            PutObjectArgs.builder()
-                .contentType("application/octet-stream")
-                .bucket(bucketName)
-                .object(key)
-                .stream(pipedInputStream, -1, fileSystem.getPartSize())
-                .build();
-        fileSystem.getClient().putObject(args);
-      } catch (Exception e) {
-        // Recorded and re-thrown from close() so the failure is surfaced 
instead of swallowed.
-        transferError = e;
-      }
+    byte[] data = buffer.toByteArray();
+    try {
+      PutObjectArgs args =
+          PutObjectArgs.builder()
+              .contentType("application/octet-stream")
+              .bucket(bucketName)
+              .object(key)
+              .stream(new ByteArrayInputStream(data), data.length, -1)
+              .build();
+      fileSystem.getClient().putObject(args);
+      fileSystem.invalidateListCacheForParentOf(bucketName, key);
+    } catch (Exception e) {
+      throw new IOException("MinIO upload failed for " + bucketName + "/" + 
key, e);
+    } finally {
+      buffer.reset();
     }
   }
+
+  public boolean isClosed() {
+    return closed;
+  }
 }
diff --git 
a/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/util/MinioPipedOutputStreamTest.java
 
b/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/util/MinioPipedOutputStreamTest.java
index bba47ae1e3..5ff1daf4cc 100644
--- 
a/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/util/MinioPipedOutputStreamTest.java
+++ 
b/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/util/MinioPipedOutputStreamTest.java
@@ -17,76 +17,72 @@
 
 package org.apache.hop.vfs.minio.util;
 
-import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import io.minio.MinioClient;
 import io.minio.PutObjectArgs;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
+import java.io.InputStream;
 import org.apache.hop.vfs.minio.MinioFileSystem;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 
 /**
- * Regression guard for the MinIO VFS write-path forever-hang — same fix shape 
as {@code
- * S3CommonPipedOutputStream}: {@link MinioPipedOutputStream#close()} now 
joins the uploader thread
- * interruptibly and surfaces upload failures instead of polling forever and 
swallowing them.
+ * Regression guard for MinIO VFS writes: content must be uploaded with a 
known length (no
+ * pipe+unknown-size races) and upload failures must surface from {@code 
close()}.
  */
 class MinioPipedOutputStreamTest {
 
   @Test
-  void closeIsInterruptibleWhenPutObjectStalls() throws Exception {
-    CountDownLatch putEntered = new CountDownLatch(1);
-    CountDownLatch release = new CountDownLatch(1); // models a stalled MinIO 
endpoint
+  void closeUploadsBufferedContentWithKnownLength() throws Exception {
+    MinioClient client = mock(MinioClient.class);
+    when(client.putObject(any(PutObjectArgs.class))).thenReturn(null);
+
+    MinioFileSystem fileSystem = mock(MinioFileSystem.class);
+    when(fileSystem.getClient()).thenReturn(client);
+
+    MinioPipedOutputStream out =
+        new MinioPipedOutputStream(fileSystem, "test-bucket", "path/file.txt");
+    byte[] payload = "hello-minio".getBytes();
+    out.write(payload);
+    out.close();
+
+    ArgumentCaptor<PutObjectArgs> argsCaptor = 
ArgumentCaptor.forClass(PutObjectArgs.class);
+    verify(client).putObject(argsCaptor.capture());
+    PutObjectArgs args = argsCaptor.getValue();
+    assertEquals("test-bucket", args.bucket());
+    assertEquals("path/file.txt", args.object());
+    assertEquals(payload.length, args.objectSize());
+
+    try (InputStream stream = args.stream()) {
+      assertArrayEquals(payload, stream.readAllBytes());
+    }
 
+    verify(fileSystem).invalidateListCacheForParentOf(eq("test-bucket"), 
eq("path/file.txt"));
+  }
+
+  @Test
+  void closeWithNoWritesUploadsEmptyObject() throws Exception {
     MinioClient client = mock(MinioClient.class);
-    when(client.putObject(any(PutObjectArgs.class)))
-        .thenAnswer(
-            inv -> {
-              putEntered.countDown();
-              release.await();
-              return null;
-            });
+    when(client.putObject(any(PutObjectArgs.class))).thenReturn(null);
 
     MinioFileSystem fileSystem = mock(MinioFileSystem.class);
     when(fileSystem.getClient()).thenReturn(client);
-    when(fileSystem.getPartSize()).thenReturn(5L * 1024 * 1024);
 
-    MinioPipedOutputStream out = new MinioPipedOutputStream(fileSystem, 
"test-bucket", "test-key");
+    MinioPipedOutputStream out = new MinioPipedOutputStream(fileSystem, 
"bucket", "empty.txt");
+    out.close();
 
-    Thread closer =
-        new Thread(
-            () -> {
-              try {
-                out.close();
-              } catch (Exception ignored) {
-                // InterruptedIOException expected once we interrupt
-              }
-            },
-            "minio-piped-closer");
-    closer.setDaemon(true);
-    closer.start();
-
-    try {
-      assertTrue(putEntered.await(5, TimeUnit.SECONDS), "background putObject 
should have started");
-
-      closer.join(1500);
-      assertTrue(closer.isAlive(), "close() should still be waiting for the 
in-progress upload");
-
-      closer.interrupt();
-      closer.join(3000);
-      assertFalse(
-          closer.isAlive(),
-          "close() did not respond to interruption — the uninterruptible 
forever-hang has been "
-              + "reintroduced");
-    } finally {
-      release.countDown();
-    }
+    ArgumentCaptor<PutObjectArgs> argsCaptor = 
ArgumentCaptor.forClass(PutObjectArgs.class);
+    verify(client).putObject(argsCaptor.capture());
+    assertEquals(0L, argsCaptor.getValue().objectSize());
   }
 
   @Test
@@ -97,7 +93,6 @@ class MinioPipedOutputStreamTest {
 
     MinioFileSystem fileSystem = mock(MinioFileSystem.class);
     when(fileSystem.getClient()).thenReturn(client);
-    when(fileSystem.getPartSize()).thenReturn(5L * 1024 * 1024);
 
     MinioPipedOutputStream out = new MinioPipedOutputStream(fileSystem, 
"test-bucket", "test-key");
     out.write(new byte[] {1, 2, 3});
@@ -107,4 +102,68 @@ class MinioPipedOutputStreamTest {
         out::close,
         "a failed MinIO upload must be reported from close(), not silently 
swallowed");
   }
+
+  @Test
+  void writeAfterCloseIsAcceptedAndUploadedOnNextClose() throws Exception {
+    // Nested VFS/Text File Output close chains may flush after the first 
close; late bytes must
+    // still be uploaded on a subsequent close().
+    MinioClient client = mock(MinioClient.class);
+    when(client.putObject(any(PutObjectArgs.class))).thenReturn(null);
+
+    MinioFileSystem fileSystem = mock(MinioFileSystem.class);
+    when(fileSystem.getClient()).thenReturn(client);
+
+    MinioPipedOutputStream out = new MinioPipedOutputStream(fileSystem, 
"test-bucket", "key");
+    out.close(); // first close uploads empty
+
+    out.write(new byte[] {1, 2, 3});
+    out.close(); // second close uploads the late bytes
+
+    // first empty + second with content
+    verify(client, 
org.mockito.Mockito.times(2)).putObject(any(PutObjectArgs.class));
+  }
+
+  @Test
+  void closeIsIdempotent() throws Exception {
+    MinioClient client = mock(MinioClient.class);
+    when(client.putObject(any(PutObjectArgs.class))).thenReturn(null);
+
+    MinioFileSystem fileSystem = mock(MinioFileSystem.class);
+    when(fileSystem.getClient()).thenReturn(client);
+
+    MinioPipedOutputStream out = new MinioPipedOutputStream(fileSystem, 
"test-bucket", "key");
+    out.write(new byte[] {9});
+    out.close();
+    out.close(); // second close must not re-upload or throw
+
+    verify(client).putObject(any(PutObjectArgs.class));
+  }
+
+  @Test
+  void largeWriteIsFullyUploaded() throws Exception {
+    MinioClient client = mock(MinioClient.class);
+    when(client.putObject(any(PutObjectArgs.class))).thenReturn(null);
+
+    MinioFileSystem fileSystem = mock(MinioFileSystem.class);
+    when(fileSystem.getClient()).thenReturn(client);
+
+    byte[] payload = new byte[256 * 1024];
+    for (int i = 0; i < payload.length; i++) {
+      payload[i] = (byte) (i % 251);
+    }
+
+    MinioPipedOutputStream out = new MinioPipedOutputStream(fileSystem, 
"test-bucket", "large.bin");
+    out.write(payload);
+    out.close();
+
+    ArgumentCaptor<PutObjectArgs> argsCaptor = 
ArgumentCaptor.forClass(PutObjectArgs.class);
+    verify(client).putObject(argsCaptor.capture());
+    assertEquals(payload.length, argsCaptor.getValue().objectSize());
+
+    try (InputStream stream = argsCaptor.getValue().stream();
+        ByteArrayOutputStream readBack = new ByteArrayOutputStream()) {
+      stream.transferTo(readBack);
+      assertArrayEquals(payload, readBack.toByteArray());
+    }
+  }
 }
diff --git 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
index 1ea5ea7728..b0445b34eb 100644
--- 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
+++ 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
@@ -710,9 +710,10 @@ public class NeoExecutionInfoLocation implements 
IExecutionInfoLocation {
         }
         builder.withExtraClause("n." + EP_EXECUTION_TYPE + " IN [ 'Workflow', 
'Pipeline' ]");
       }
-      if (selector.startDateFilter() != LastPeriod.NONE) {
+      LastPeriod dateFilter = selector.startDateFilter();
+      if (dateFilter != null && dateFilter != LastPeriod.NONE) {
         builder.withExtraClause(" AND n." + EP_EXECUTION_START_DATE + " >= 
$fromStartDate ");
-        builder.parameters().put("fromStartDate", 
selector.startDateFilter().calculateStartDate());
+        builder.parameters().put("fromStartDate", 
dateFilter.calculateStartDate());
       }
     }
 
diff --git 
a/plugins/tech/opensearch/src/main/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocation.java
 
b/plugins/tech/opensearch/src/main/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocation.java
index f23228fefe..994d394a3e 100644
--- 
a/plugins/tech/opensearch/src/main/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocation.java
+++ 
b/plugins/tech/opensearch/src/main/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocation.java
@@ -430,11 +430,12 @@ public class OpenSearchExecutionInfoLocation extends 
BaseCachingExecutionInfoLoc
       body = body.replace("__FROM_CLAUSE__", "FROM " + actualIndexName);
 
       String whereClause = "";
-      if (selector != null && selector.startDateFilter() != LastPeriod.NONE) {
+      LastPeriod dateFilter = selector != null ? selector.startDateFilter() : 
null;
+      if (dateFilter != null && dateFilter != LastPeriod.NONE) {
         // OpenSearch uses UTC and the GUI runs in local time.
         //
         ZonedDateTime localStartDate =
-            
selector.startDateFilter().calculateStartDate().atZone(ZoneId.systemDefault());
+            dateFilter.calculateStartDate().atZone(ZoneId.systemDefault());
         Date localStart = new Date(localStartDate.toInstant().toEpochMilli());
 
         SimpleDateFormat whereFormat = new SimpleDateFormat("yyyy-MM-dd 
HH:mm:ss");
diff --git 
a/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
 
b/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
index f4d83347a8..6a761bd818 100644
--- 
a/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
+++ 
b/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
@@ -865,7 +865,9 @@ public class Rest extends BaseTransform<RestMeta, RestData> 
{
     if ("[]".equals(bodyTrim)) {
       return true;
     }
-    if 
(!RestMeta.APPLICATION_TYPE_JSON.equals(NVL(resolve(meta.getApplicationType()), 
""))) {
+    // Response may be JSON even when the request Content-Type is 
form-urlencoded (e.g. Slack).
+    if (!shouldParseResponseAsJson(
+        NVL(resolve(meta.getApplicationType()), ""), resolvedSplitPath)) {
       return false;
     }
     try {
@@ -886,6 +888,19 @@ public class Rest extends BaseTransform<RestMeta, 
RestData> {
     }
   }
 
+  /**
+   * Whether a response body should be parsed as JSON for pagination/split. 
Application type is the
+   * <em>request</em> Content-Type; APIs such as Slack POST form bodies and 
still return JSON.
+   * JsonPath expressions (starting with {@code $}) imply a JSON response 
regardless of request
+   * type.
+   */
+  static boolean shouldParseResponseAsJson(String applicationType, String 
pathOrExpression) {
+    if (RestMeta.APPLICATION_TYPE_JSON.equals(NVL(applicationType, ""))) {
+      return true;
+    }
+    return pathOrExpression != null && pathOrExpression.trim().startsWith("$");
+  }
+
   private int emitPagedResultRows(
       Object[] rowTemplate, String resolvedSplitPath, RestExchangeResult ex) 
throws HopException {
 
@@ -916,7 +931,9 @@ public class Rest extends BaseTransform<RestMeta, RestData> 
{
 
     List<String> out = new ArrayList<>();
     try {
-      if (RestMeta.APPLICATION_TYPE_JSON.equals(appType)) {
+      // Prefer JsonPath when the expression is JsonPath or the request type 
is JSON. Request
+      // Content-Type (e.g. FORM URLENCODED) does not dictate the response 
payload format.
+      if (shouldParseResponseAsJson(appType, jsonOrXPathExpr)) {
         Object raw =
             JsonPath.using(JSON_PATH_CONFIGURATION).parse(NVL(body, 
"{}")).read(jsonOrXPathExpr);
         appendStructuredJsonPieces(out, raw);
@@ -960,7 +977,9 @@ public class Rest extends BaseTransform<RestMeta, RestData> 
{
       }
 
       throw new HopException(
-          "REST resultSplitPath requires application type XML or JSON, got '" 
+ appType + "'.");
+          "REST resultSplitPath requires a JsonPath (starting with $) or XML 
application type, got '"
+              + appType
+              + "'.");
 
     } catch (HopException he) {
       throw he;
@@ -1006,14 +1025,13 @@ public class Rest extends BaseTransform<RestMeta, 
RestData> {
     }
     String appType = NVL(resolve(meta.getApplicationType()), "");
     try {
-      if (RestMeta.APPLICATION_TYPE_JSON.equals(appType)) {
-        if (Utils.isEmpty(conn.getCursorJsonPath())) {
-          return java.util.Optional.empty();
-        }
+      String cursorJsonPath = conn.getCursorJsonPath();
+      if (shouldParseResponseAsJson(appType, resolve(cursorJsonPath))
+          && !Utils.isEmpty(cursorJsonPath)) {
         Object raw =
             JsonPath.using(JSON_PATH_CONFIGURATION)
                 .parse(NVL(body, "{}"))
-                .read(resolve(conn.getCursorJsonPath()));
+                .read(resolve(cursorJsonPath));
         if (raw == null) {
           return java.util.Optional.empty();
         }
@@ -1052,14 +1070,13 @@ public class Rest extends BaseTransform<RestMeta, 
RestData> {
     }
     String appType = NVL(resolve(meta.getApplicationType()), "");
     try {
-      if (RestMeta.APPLICATION_TYPE_JSON.equals(appType)) {
-        if (Utils.isEmpty(conn.getNextPageUrlJsonPath())) {
-          return java.util.Optional.empty();
-        }
+      String nextPageUrlJsonPath = conn.getNextPageUrlJsonPath();
+      if (shouldParseResponseAsJson(appType, resolve(nextPageUrlJsonPath))
+          && !Utils.isEmpty(nextPageUrlJsonPath)) {
         Object raw =
             JsonPath.using(JSON_PATH_CONFIGURATION)
                 .parse(NVL(body, "{}"))
-                
.read(resolveSplitPathOrPagingExpression(conn.getNextPageUrlJsonPath()));
+                .read(resolveSplitPathOrPagingExpression(nextPageUrlJsonPath));
         return normalizeNextPageUrl(raw);
       }
 
diff --git 
a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/ResponseJsonParseTest.java
 
b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/ResponseJsonParseTest.java
new file mode 100644
index 0000000000..bf7e8e8c23
--- /dev/null
+++ 
b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/ResponseJsonParseTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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.hop.pipeline.transforms.rest;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Response body parsing for pagination/split must not be locked to the 
request Content-Type.
+ * Slack-style FORM posts still return JSON responses addressed by JsonPath.
+ */
+class ResponseJsonParseTest {
+
+  private TransformMockHelper<RestMeta, RestData> mockHelper;
+  private Rest rest;
+
+  @BeforeEach
+  void setUp() {
+    mockHelper = new TransformMockHelper<>("REST form+jsonpath", 
RestMeta.class, RestData.class);
+    when(mockHelper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+        .thenReturn(mockHelper.iLogChannel);
+
+    RestMeta meta = new RestMeta();
+    meta.setApplicationType(RestMeta.APPLICATION_TYPE_FORM_URLENCODED);
+    RestData data = new RestData();
+    rest =
+        new Rest(
+            mockHelper.transformMeta, meta, data, 0, mockHelper.pipelineMeta, 
mockHelper.pipeline);
+  }
+
+  @AfterEach
+  void tearDown() {
+    mockHelper.cleanUp();
+  }
+
+  @Test
+  void shouldParseResponseAsJsonWhenAppTypeIsJson() {
+    assertTrue(Rest.shouldParseResponseAsJson(RestMeta.APPLICATION_TYPE_JSON, 
"not-a-path"));
+  }
+
+  @Test
+  void shouldParseResponseAsJsonWhenPathIsJsonPath() {
+    assertTrue(
+        
Rest.shouldParseResponseAsJson(RestMeta.APPLICATION_TYPE_FORM_URLENCODED, 
"$.channels[*]"));
+  }
+
+  @Test
+  void shouldNotParseAsJsonWithoutJsonAppTypeOrJsonPath() {
+    
assertFalse(Rest.shouldParseResponseAsJson(RestMeta.APPLICATION_TYPE_FORM_URLENCODED,
 "/item"));
+    
assertFalse(Rest.shouldParseResponseAsJson(RestMeta.APPLICATION_TYPE_TEXT_PLAIN,
 null));
+  }
+
+  @Test
+  void formUrlEncodedWithJsonPathAllowsEmptyPagedSplitCheck() {
+    assertTrue(
+        rest.isLegitimateEmptyPagedSplit("{\"channels\":[]}", "$.channels[*]"),
+        "FORM request + JsonPath split on empty JSON array must be treated as 
legitimate empty");
+  }
+}


Reply via email to