From 36ac40c62e9696eefee6af6fe563f8dd79de935c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 3 Mar 2026 21:30:12 +0000
Subject: [PATCH 1/4] Add idempotence-s3fs-nio module for S3 bucket test
resource generation
Co-authored-by: carlspring <1436265+carlspring@users.noreply.github.com>
---
.../modules/idempotence-s3fs-nio.md | 55 ++++++
idempotence-s3fs-nio/pom.xml | 144 +++++++++++++++
.../config/S3fsNioIdempotenceProperties.java | 46 +++++
.../extension/S3TestResourceExtension.java | 148 ++++++++++++++++
.../idempotence/io/S3ResourceCopier.java | 167 ++++++++++++++++++
.../io/S3fsNioPathTransformer.java | 80 +++++++++
.../idempotence/util/S3FileSystemUtils.java | 66 +++++++
...g.idempotence.config.IdempotenceProperties | 1 +
...ing.testing.idempotence.io.PathTransformer | 1 +
.../io/S3fsNioPathTransformerTest.java | 82 +++++++++
.../util/S3fsNioTestMethodUtilsTest.java | 55 ++++++
.../src/test/resources/foo.txt | 1 +
.../src/test/resources/logback.xml | 46 +++++
.../src/test/resources/nested/dir/foo.txt | 1 +
pom.xml | 1 +
15 files changed, 894 insertions(+)
create mode 100644 docs/content/developers-guide/modules/idempotence-s3fs-nio.md
create mode 100644 idempotence-s3fs-nio/pom.xml
create mode 100644 idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/config/S3fsNioIdempotenceProperties.java
create mode 100644 idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/extension/S3TestResourceExtension.java
create mode 100644 idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/io/S3ResourceCopier.java
create mode 100644 idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/io/S3fsNioPathTransformer.java
create mode 100644 idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/util/S3FileSystemUtils.java
create mode 100644 idempotence-s3fs-nio/src/main/resources/META-INF/services/org.carlspring.testing.idempotence.config.IdempotenceProperties
create mode 100644 idempotence-s3fs-nio/src/main/resources/META-INF/services/org.carlspring.testing.idempotence.io.PathTransformer
create mode 100644 idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/io/S3fsNioPathTransformerTest.java
create mode 100644 idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/util/S3fsNioTestMethodUtilsTest.java
create mode 100644 idempotence-s3fs-nio/src/test/resources/foo.txt
create mode 100644 idempotence-s3fs-nio/src/test/resources/logback.xml
create mode 100644 idempotence-s3fs-nio/src/test/resources/nested/dir/foo.txt
diff --git a/docs/content/developers-guide/modules/idempotence-s3fs-nio.md b/docs/content/developers-guide/modules/idempotence-s3fs-nio.md
new file mode 100644
index 0000000..3d0924c
--- /dev/null
+++ b/docs/content/developers-guide/modules/idempotence-s3fs-nio.md
@@ -0,0 +1,55 @@
+# `idempotence-s3fs-nio`
+
+This Maven module adds support for generating test files in S3 buckets using the
+[carlspring/s3fs-nio](https://github.com/carlspring/s3fs-nio) library.
+
+## Directory Structure (S3)
+
+Test resources are copied to an S3 URI of the form:
+
+```
+s3:///[bucket-name]/[prefix]/[TestClass]-[testMethod]/[resource-path]
+```
+
+For example, with the default configuration:
+
+```
+s3:///idempotence-test-resources/MyTest-testSingleFile/foo.txt
+```
+
+## Configuration
+
+Set the `org.carlspring.testing.idempotence.basedir` system property to an S3 URI:
+
+| Example | Description |
+|---------|-------------|
+| `s3:///my-bucket` | Default AWS S3 endpoint, bucket `my-bucket` |
+| `s3:///my-bucket/prefix` | Default AWS S3 endpoint, bucket `my-bucket`, key prefix `prefix` |
+| `s3://localhost:9090/my-bucket` | Custom endpoint (e.g. MinIO), bucket `my-bucket` |
+
+If no system property is set, the default base directory is `s3:///idempotence-test-resources`.
+
+## Usage
+
+Use the `S3TestResourceExtension` JUnit Jupiter extension instead of (or in addition to) the
+standard `TestResourceExtension`:
+
+```java
+@ExtendWith(S3TestResourceExtension.class)
+class MyTest {
+
+ @Test
+ @TestResources(@TestResource(source = "classpath:/foo.txt"))
+ void testSingleFile() {
+ // The file is available in S3 at:
+ // s3:///idempotence-test-resources/MyTest-testSingleFile/foo.txt
+ }
+
+}
+```
+
+## S3 Credentials
+
+S3 credentials and region are resolved by the AWS SDK's default credential provider chain.
+For local development with MinIO, configure the endpoint via the system property and supply
+credentials via the standard AWS environment variables or `~/.aws/credentials`.
diff --git a/idempotence-s3fs-nio/pom.xml b/idempotence-s3fs-nio/pom.xml
new file mode 100644
index 0000000..a7eb846
--- /dev/null
+++ b/idempotence-s3fs-nio/pom.xml
@@ -0,0 +1,144 @@
+
+ * The default base directory is {@code s3:///idempotence-test-resources}, which points + * to a bucket named {@code idempotence-test-resources} on the default AWS S3 endpoint. + * Override the {@code org.carlspring.testing.idempotence.basedir} system property to + * specify a different S3 URI (e.g. {@code s3://localhost:9090/my-bucket/prefix} for + * a local S3-compatible server such as MinIO). + *
+ * + * @author carlspring + */ +@Service +public class S3fsNioIdempotenceProperties + extends AbstractIdempotenceProperties +{ + + private String basedir = System.getProperty("org.carlspring.testing.idempotence.basedir") != null ? + System.getProperty("org.carlspring.testing.idempotence.basedir") : + "s3:///idempotence-test-resources"; + + /** + * Creates a new instance of {@link S3fsNioIdempotenceProperties}. + */ + public S3fsNioIdempotenceProperties() + { + } + + @Override + public String getBasedir() + { + return basedir; + } + + @Override + public void setBasedir(String basedir) + { + this.basedir = basedir; + } + +} diff --git a/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/extension/S3TestResourceExtension.java b/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/extension/S3TestResourceExtension.java new file mode 100644 index 0000000..dcf0b24 --- /dev/null +++ b/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/extension/S3TestResourceExtension.java @@ -0,0 +1,148 @@ +package org.carlspring.testing.idempotence.extension; + +import org.carlspring.testing.idempotence.annotation.TestResources; +import org.carlspring.testing.idempotence.config.IdempotencePropertiesService; +import org.carlspring.testing.idempotence.io.S3ResourceCopier; +import org.carlspring.testing.idempotence.util.S3FileSystemUtils; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.URI; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.FileVisitResult; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Optional; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JUnit Jupiter extension that copies test resources to an S3 bucket before each test method + * is executed. It also cleans up the S3 "directory" before copying to ensure a clean, + * isolated environment. + *+ * Uses s3fs-nio to interact with S3 via + * the Java NIO2 API. + *
+ *+ * Configure the target S3 location by setting the + * {@code org.carlspring.testing.idempotence.basedir} system property to an S3 URI + * (e.g. {@code s3:///my-bucket/test-resources} or + * {@code s3://localhost:9090/my-bucket/test-resources} for a local MinIO instance). + *
+ * + * @author carlspring + */ +public class S3TestResourceExtension + implements BeforeEachCallback, BeforeAllCallback +{ + + private static final Logger logger = LoggerFactory.getLogger(S3TestResourceExtension.class); + + private final S3ResourceCopier s3ResourceCopier = + new S3ResourceCopier(IdempotencePropertiesService.getInstance() + .getIdempotenceProperties() + .getBasedir()); + + + /** + * Creates a new instance of {@link S3TestResourceExtension}. + */ + public S3TestResourceExtension() + { + } + + @Override + public void beforeAll(ExtensionContext context) + throws Exception + { + } + + @Override + public void beforeEach(ExtensionContext context) + throws Exception + { + cleanUp(context); + copyResources(context.getRequiredTestMethod().getAnnotation(TestResources.class), context); + } + + /** + * Removes any existing objects in the S3 "directory" for the current test method, + * ensuring a clean isolated environment for the current execution. + */ + private void cleanUp(ExtensionContext context) + throws IOException + { + String basedir = IdempotencePropertiesService.getInstance() + .getIdempotenceProperties() + .getBasedir(); + String testResourceDir = getTestResourceDirectory(context); + + URI baseUri = URI.create(basedir); + FileSystem s3FileSystem = S3FileSystemUtils.getOrCreateS3FileSystem(baseUri); + + Path s3TestDir = s3FileSystem.getPath(baseUri.getPath() + "/" + testResourceDir); + + if (!Files.exists(s3TestDir)) + { + return; + } + + Files.walkFileTree(s3TestDir, new SimpleFileVisitor<>() + { + @Override + public FileVisitResult visitFile(Path file, + BasicFileAttributes attrs) + throws IOException + { + logger.debug("Removing '{}'...", file); + + Files.deleteIfExists(file); + + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, + IOException exc) + throws IOException + { + if (!dir.equals(s3TestDir)) + { + logger.debug("Removing '{}'...", dir); + + Files.deleteIfExists(dir); + } + + return FileVisitResult.CONTINUE; + } + }); + } + + private void copyResources(TestResources testResources, + ExtensionContext context) + throws IOException + { + if (testResources != null) + { + String testResourceDir = getTestResourceDirectory(context); + s3ResourceCopier.copyResources(testResources.value(), testResourceDir); + } + } + + private String getTestResourceDirectory(ExtensionContext context) + { + String className = context.getRequiredTestClass().getSimpleName(); + Optional+ * This copier resolves the S3 {@link FileSystem} from the configured base URI and + * writes each resource using the NIO2 {@link Files#copy(java.io.InputStream, Path, java.nio.file.CopyOption...)} API. + *
+ * + * @author carlspring + */ +public class S3ResourceCopier +{ + + private static final Logger logger = LoggerFactory.getLogger(S3ResourceCopier.class); + + private final PathTransformer pathTransformer = PathTransformerService.getInstance().getPathTransformer(); + + private final String resourceBaseUri; + + + /** + * Creates a new instance of {@link S3ResourceCopier}. + * + * @param resourceBaseUri the S3 base URI to which test resources will be copied + * (e.g. {@code s3:///my-bucket/test-resources}) + */ + public S3ResourceCopier(String resourceBaseUri) + { + this.resourceBaseUri = resourceBaseUri; + } + + /** + * Copies the given test resources to the specified directory within the S3 base URI. + * + * @param testResources the array of {@link TestResource} annotations describing the resources to copy + * @param testResourceDir the relative directory within the base URI where resources should be placed + * @throws IOException if an I/O error occurs during copying + */ + public void copyResources(TestResource[] testResources, + String testResourceDir) + throws IOException + { + URI baseUri = URI.create(resourceBaseUri); + FileSystem s3FileSystem = S3FileSystemUtils.getOrCreateS3FileSystem(baseUri); + + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + + for (TestResource testResource : testResources) + { + logger.debug("testResource: {}", testResource.source()); + + String sourcePath = testResource.source(); + + Resource[] resources = resolver.getResources(sourcePath); + + for (Resource resource : resources) + { + try + { + String relativePath = resolveRelativePath(testResource, resource); + + Path s3DestDir = buildS3DestinationDir(s3FileSystem, baseUri, testResourceDir, testResource); + Path s3DestFile = s3DestDir.resolve(relativePath); + + if (logger.isDebugEnabled()) + { + logger.debug("relativePath: {}", relativePath); + logger.debug("s3DestDir: {}", s3DestDir); + logger.debug("s3DestFile: {}", s3DestFile); + } + + createParentDirectories(s3DestFile); + + logger.debug("Copying {} to {}...", resource.getDescription(), s3DestFile); + + Files.copy(resource.getInputStream(), + s3DestFile, + StandardCopyOption.REPLACE_EXISTING); + } + catch (IOException e) + { + logger.error(e.getMessage(), e); + } + } + } + } + + private String resolveRelativePath(TestResource testResource, + Resource resource) + throws IOException + { + if (resource instanceof ClassPathResource classPathResource) + { + if (testResource.destinationDir().isEmpty()) + { + return pathTransformer.relativize(classPathResource.getFile().toPath()).toString(); + } + else + { + return pathTransformer.relativize( + classPathResource.getFile().toPath().getFileName()).toString(); + } + } + else + { + if (testResource.destinationDir().isEmpty()) + { + return pathTransformer.relativize(resource.getFile().toPath()).toString(); + } + else + { + return pathTransformer.relativize( + resource.getFile().toPath().getFileName()).toString(); + } + } + } + + private Path buildS3DestinationDir(FileSystem s3FileSystem, + URI baseUri, + String testResourceDir, + TestResource testResource) + { + String basePath = baseUri.getPath(); + + String destDirPath = !testResource.destinationDir().isEmpty() ? + basePath + "/" + testResourceDir + "/" + testResource.destinationDir() : + basePath + "/" + testResourceDir; + + return s3FileSystem.getPath(destDirPath); + } + + private void createParentDirectories(Path path) + { + Path parent = path.getParent(); + if (parent != null) + { + try + { + Files.createDirectories(parent); + } + catch (IOException e) + { + logger.debug("Could not create parent directories for {}: {}", path, e.getMessage()); + } + } + } + +} diff --git a/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/io/S3fsNioPathTransformer.java b/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/io/S3fsNioPathTransformer.java new file mode 100644 index 0000000..f27beee --- /dev/null +++ b/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/io/S3fsNioPathTransformer.java @@ -0,0 +1,80 @@ +package org.carlspring.testing.idempotence.io; + +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * S3-specific implementation of {@link PathTransformer}. + *+ * Relativizes classpath resource paths so that only the meaningful portion + * (relative to the classpath root) is used when constructing destination paths in S3. + * Handles both Maven ({@code target/test-classes}) and Gradle + * ({@code build/resources/test}, {@code build/classes/java/test}) build output directories. + *
+ * + * @author carlspring + */ +public class S3fsNioPathTransformer + implements PathTransformer +{ + + private static final String MAVEN_TEST_CLASSES = "target/test-classes"; + + private static final String GRADLE_RESOURCES_TEST = "build/resources/test"; + + private static final String GRADLE_CLASSES_TEST = "build/classes/java/test"; + + + /** + * Creates a new instance of {@link S3fsNioPathTransformer}. + */ + public S3fsNioPathTransformer() + { + } + + /** + * At present, this is a no-op method. + * + * @param basePath the base path (unused) + * @param path the resource path to transform + * @return the unchanged resource path + */ + @Override + public Path transform(Path basePath, Path path) + { + return path; + } + + /** + * Relativizes the path based on known Maven and Gradle build output directory patterns. + * + * @param path the path to relativize + * @return the relativized path, with any known build output directory prefix removed + */ + @Override + public Path relativize(Path path) + { + String pathString = path.toString(); + + if (pathString.contains(MAVEN_TEST_CLASSES)) + { + return Paths.get(pathString.substring(pathString.indexOf(MAVEN_TEST_CLASSES) + + MAVEN_TEST_CLASSES.length() + 1)); + } + else if (pathString.contains(GRADLE_RESOURCES_TEST)) + { + return Paths.get(pathString.substring(pathString.indexOf(GRADLE_RESOURCES_TEST) + + GRADLE_RESOURCES_TEST.length() + 1)); + } + else if (pathString.contains(GRADLE_CLASSES_TEST)) + { + return Paths.get(pathString.substring(pathString.indexOf(GRADLE_CLASSES_TEST) + + GRADLE_CLASSES_TEST.length() + 1)); + } + else + { + return path; + } + } + +} diff --git a/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/util/S3FileSystemUtils.java b/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/util/S3FileSystemUtils.java new file mode 100644 index 0000000..0cc653f --- /dev/null +++ b/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/util/S3FileSystemUtils.java @@ -0,0 +1,66 @@ +package org.carlspring.testing.idempotence.util; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.util.Collections; + +/** + * Utility class for working with S3 {@link FileSystem} instances via the s3fs-nio provider. + * + * @author carlspring + */ +public final class S3FileSystemUtils +{ + + private S3FileSystemUtils() + { + } + + /** + * Returns an existing S3 {@link FileSystem} for the root URI derived from the given base URI, + * or creates and registers a new one if none exists yet. + *+ * The root URI is constructed from the scheme, optional host, and optional port of the provided + * URI (e.g. {@code s3:///} or {@code s3://localhost:9090/}). + *
+ * + * @param baseUri the S3 base URI (e.g. {@code s3:///my-bucket/prefix}) + * @return the S3 {@link FileSystem} + * @throws IOException if an I/O error occurs while creating a new {@link FileSystem} + */ + public static FileSystem getOrCreateS3FileSystem(URI baseUri) + throws IOException + { + URI s3RootUri = buildRootUri(baseUri); + try + { + return FileSystems.getFileSystem(s3RootUri); + } + catch (java.nio.file.FileSystemNotFoundException e) + { + return FileSystems.newFileSystem(s3RootUri, Collections.emptyMap()); + } + } + + private static URI buildRootUri(URI baseUri) + { + StringBuilder sb = new StringBuilder(baseUri.getScheme()).append("://"); + + if (baseUri.getHost() != null) + { + sb.append(baseUri.getHost()); + } + + if (baseUri.getPort() != -1) + { + sb.append(":").append(baseUri.getPort()); + } + + sb.append("/"); + + return URI.create(sb.toString()); + } + +} diff --git a/idempotence-s3fs-nio/src/main/resources/META-INF/services/org.carlspring.testing.idempotence.config.IdempotenceProperties b/idempotence-s3fs-nio/src/main/resources/META-INF/services/org.carlspring.testing.idempotence.config.IdempotenceProperties new file mode 100644 index 0000000..3801705 --- /dev/null +++ b/idempotence-s3fs-nio/src/main/resources/META-INF/services/org.carlspring.testing.idempotence.config.IdempotenceProperties @@ -0,0 +1 @@ +org.carlspring.testing.idempotence.config.S3fsNioIdempotenceProperties diff --git a/idempotence-s3fs-nio/src/main/resources/META-INF/services/org.carlspring.testing.idempotence.io.PathTransformer b/idempotence-s3fs-nio/src/main/resources/META-INF/services/org.carlspring.testing.idempotence.io.PathTransformer new file mode 100644 index 0000000..cea123f --- /dev/null +++ b/idempotence-s3fs-nio/src/main/resources/META-INF/services/org.carlspring.testing.idempotence.io.PathTransformer @@ -0,0 +1 @@ +org.carlspring.testing.idempotence.io.S3fsNioPathTransformer diff --git a/idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/io/S3fsNioPathTransformerTest.java b/idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/io/S3fsNioPathTransformerTest.java new file mode 100644 index 0000000..8039849 --- /dev/null +++ b/idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/io/S3fsNioPathTransformerTest.java @@ -0,0 +1,82 @@ +package org.carlspring.testing.idempotence.io; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author carlspring + */ +class S3fsNioPathTransformerTest +{ + + @Test + void testTransformIsNoOp() + { + Path basePath = Paths.get("target/test-classes"); + Path resourcePath = Paths.get("foo.txt"); + + S3fsNioPathTransformer transformer = new S3fsNioPathTransformer(); + Path transformedPath = transformer.transform(basePath, resourcePath); + + assertEquals("foo.txt", transformedPath.toString()); + } + + @Test + void testRelativizeMavenPath() + { + Path path = Paths.get("/home/runner/work/project/target/test-classes/foo.txt"); + + S3fsNioPathTransformer transformer = new S3fsNioPathTransformer(); + Path relativized = transformer.relativize(path); + + assertEquals("foo.txt", relativized.toString()); + } + + @Test + void testRelativizeMavenNestedPath() + { + Path path = Paths.get("/home/runner/work/project/target/test-classes/nested/dir/foo.txt"); + + S3fsNioPathTransformer transformer = new S3fsNioPathTransformer(); + Path relativized = transformer.relativize(path); + + assertEquals("nested/dir/foo.txt", relativized.toString()); + } + + @Test + void testRelativizeGradleResourcesPath() + { + Path path = Paths.get("/home/runner/work/project/build/resources/test/foo.txt"); + + S3fsNioPathTransformer transformer = new S3fsNioPathTransformer(); + Path relativized = transformer.relativize(path); + + assertEquals("foo.txt", relativized.toString()); + } + + @Test + void testRelativizeGradleClassesPath() + { + Path path = Paths.get("/home/runner/work/project/build/classes/java/test/nested/dir/foo.txt"); + + S3fsNioPathTransformer transformer = new S3fsNioPathTransformer(); + Path relativized = transformer.relativize(path); + + assertEquals("nested/dir/foo.txt", relativized.toString()); + } + + @Test + void testRelativizeUnknownPath() + { + Path path = Paths.get("foo.txt"); + + S3fsNioPathTransformer transformer = new S3fsNioPathTransformer(); + Path relativized = transformer.relativize(path); + + assertEquals("foo.txt", relativized.toString()); + } + +} diff --git a/idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/util/S3fsNioTestMethodUtilsTest.java b/idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/util/S3fsNioTestMethodUtilsTest.java new file mode 100644 index 0000000..5eaf569 --- /dev/null +++ b/idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/util/S3fsNioTestMethodUtilsTest.java @@ -0,0 +1,55 @@ +package org.carlspring.testing.idempotence.util; + +import org.carlspring.testing.idempotence.config.IdempotencePropertiesService; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author carlspring + */ +// This is running in sequential mode because we're overriding the default behavior of the +// IdempotenceProperties for the purpose of testing. +@Execution(ExecutionMode.SAME_THREAD) +class S3fsNioTestMethodUtilsTest +{ + + + @Test + void testMethodsDefault() + { + TestInvocationDetails details = TestMethodService.getTestInvocationDetails(Thread.currentThread().getStackTrace()); + + assertEquals("S3fsNioTestMethodUtilsTest", details.getClassName()); + assertEquals("testMethodsDefault", details.getMethodName()); + assertEquals("s3:///idempotence-test-resources/S3fsNioTestMethodUtilsTest-testMethodsDefault", + details.getPathToMethodTestResources()); + } + + @Test + void testMethodsWithFQDNPath() + { + IdempotencePropertiesService.getInstance() + .getIdempotenceProperties() + .setUseFullyQualifiedClassNamePrefixes(true); + + try + { + TestInvocationDetails details = TestMethodService.getTestInvocationDetails(Thread.currentThread().getStackTrace()); + + assertEquals("org/carlspring/testing/idempotence/util/S3fsNioTestMethodUtilsTest", details.getClassName()); + assertEquals("testMethodsWithFQDNPath", details.getMethodName()); + assertEquals("s3:///idempotence-test-resources/org/carlspring/testing/idempotence/util/S3fsNioTestMethodUtilsTest-testMethodsWithFQDNPath", + details.getPathToMethodTestResources()); + } + finally + { + IdempotencePropertiesService.getInstance() + .getIdempotenceProperties() + .setUseFullyQualifiedClassNamePrefixes(false); + } + } + +} diff --git a/idempotence-s3fs-nio/src/test/resources/foo.txt b/idempotence-s3fs-nio/src/test/resources/foo.txt new file mode 100644 index 0000000..7c6ded1 --- /dev/null +++ b/idempotence-s3fs-nio/src/test/resources/foo.txt @@ -0,0 +1 @@ +foo.txt diff --git a/idempotence-s3fs-nio/src/test/resources/logback.xml b/idempotence-s3fs-nio/src/test/resources/logback.xml new file mode 100644 index 0000000..7403512 --- /dev/null +++ b/idempotence-s3fs-nio/src/test/resources/logback.xml @@ -0,0 +1,46 @@ + + +* The default base directory is {@code s3:///idempotence-test-resources}, which points * to a bucket named {@code idempotence-test-resources} on the default AWS S3 endpoint. - * Override the {@code org.carlspring.testing.idempotence.basedir} system property to + * Override the {@code org.carlspring.testing.idempotence.s3fs.basedir} system property to * specify a different S3 URI (e.g. {@code s3://localhost:9090/my-bucket/prefix} for * a local S3-compatible server such as MinIO). *
@@ -20,8 +20,8 @@ public class S3fsNioIdempotenceProperties extends AbstractIdempotenceProperties { - private String basedir = System.getProperty("org.carlspring.testing.idempotence.basedir") != null ? - System.getProperty("org.carlspring.testing.idempotence.basedir") : + private String basedir = System.getProperty("org.carlspring.testing.idempotence.s3fs.basedir") != null ? + System.getProperty("org.carlspring.testing.idempotence.s3fs.basedir") : "s3:///idempotence-test-resources"; /** diff --git a/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/extension/S3TestResourceExtension.java b/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/extension/S3TestResourceExtension.java index dcf0b24..ef26595 100644 --- a/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/extension/S3TestResourceExtension.java +++ b/idempotence-s3fs-nio/src/main/java/org/carlspring/testing/idempotence/extension/S3TestResourceExtension.java @@ -32,7 +32,7 @@ * ** Configure the target S3 location by setting the - * {@code org.carlspring.testing.idempotence.basedir} system property to an S3 URI + * {@code org.carlspring.testing.idempotence.s3fs.basedir} system property to an S3 URI * (e.g. {@code s3:///my-bucket/test-resources} or * {@code s3://localhost:9090/my-bucket/test-resources} for a local MinIO instance). *
From 4de4179d2137c8b6a87b6fc2318a4d8a05330afc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:22:47 +0000 Subject: [PATCH 3/4] Add S3BasicFunctionalityTest with MinIO Testcontainer and lazy S3ResourceCopier Co-authored-by: carlspring <1436265+carlspring@users.noreply.github.com> --- idempotence-s3fs-nio/pom.xml | 20 +++ .../extension/S3TestResourceExtension.java | 10 +- .../idempotence/S3BasicFunctionalityTest.java | 164 ++++++++++++++++++ 3 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/S3BasicFunctionalityTest.java diff --git a/idempotence-s3fs-nio/pom.xml b/idempotence-s3fs-nio/pom.xml index a7eb846..bb327ec 100644 --- a/idempotence-s3fs-nio/pom.xml +++ b/idempotence-s3fs-nio/pom.xml @@ -22,6 +22,7 @@A MinIO instance is started automatically via Testcontainers before the tests run. + * The test is skipped when Docker is not available.
+ * + * @author carlspring + */ +@Testcontainers(disabledWithoutDocker = true) +@ExtendWith(S3TestResourceExtension.class) +class S3BasicFunctionalityTest +{ + + static final String BUCKET = "idempotence-test-resources"; + + @Container + static final MinIOContainer minio = new MinIOContainer("minio/minio:RELEASE.2025-09-07T16-13-09Z"); + + @BeforeAll + static void setUp() + throws Exception + { + URI s3Endpoint = URI.create(minio.getS3URL()); + String host = s3Endpoint.getHost(); + int port = s3Endpoint.getPort(); + + // Configure s3fs-nio system properties for MinIO + System.setProperty("s3fs.access.key", minio.getUserName()); + System.setProperty("s3fs.secret.key", minio.getPassword()); + System.setProperty("s3fs.region", "us-east-1"); + System.setProperty("s3fs.path.style.access", "true"); + System.setProperty("s3fs.protocol", "http"); + + // Update the idempotence basedir to point at the MinIO instance + String basedir = "s3://" + host + ":" + port + "/" + BUCKET; + S3fsNioIdempotenceProperties props = new S3fsNioIdempotenceProperties(); + props.setBasedir(basedir); + IdempotencePropertiesService.getInstance().setIdempotenceProperties(props); + + // Create the test bucket via the AWS SDK + try (S3Client s3Client = S3Client.builder() + .endpointOverride(URI.create(minio.getS3URL())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(minio.getUserName(), + minio.getPassword()))) + .region(Region.US_EAST_1) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build()) + .httpClient(ApacheHttpClient.builder().build()) + .build()) + { + s3Client.createBucket(b -> b.bucket(BUCKET)); + } + } + + @AfterAll + static void tearDown() + { + // Close the S3 filesystem to release resources + String basedir = IdempotencePropertiesService.getInstance() + .getIdempotenceProperties() + .getBasedir(); + try + { + FileSystem s3fs = S3FileSystemUtils.getOrCreateS3FileSystem(URI.create(basedir)); + s3fs.close(); + } + catch (Exception ignored) + { + } + + // Restore the default idempotence properties + IdempotencePropertiesService.getInstance().setIdempotenceProperties(new S3fsNioIdempotenceProperties()); + + // Remove s3fs system properties set for MinIO + System.clearProperty("s3fs.access.key"); + System.clearProperty("s3fs.secret.key"); + System.clearProperty("s3fs.region"); + System.clearProperty("s3fs.path.style.access"); + System.clearProperty("s3fs.protocol"); + } + + @Test + @TestResources(@TestResource(source = "classpath:/foo.txt")) + void testSingleFile() + throws Exception + { + String basedir = IdempotencePropertiesService.getInstance() + .getIdempotenceProperties() + .getBasedir(); + URI baseUri = URI.create(basedir); + FileSystem s3fs = S3FileSystemUtils.getOrCreateS3FileSystem(baseUri); + Path s3File = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityTest-testSingleFile/foo.txt"); + + Assertions.assertTrue(Files.exists(s3File), "Test resource file should exist in S3!"); + } + + @Test + @TestResources(@TestResource(source = "classpath:/**/foo.txt")) + void testWithPatterns() + throws Exception + { + String basedir = IdempotencePropertiesService.getInstance() + .getIdempotenceProperties() + .getBasedir(); + URI baseUri = URI.create(basedir); + FileSystem s3fs = S3FileSystemUtils.getOrCreateS3FileSystem(baseUri); + Path s3File = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityTest-testWithPatterns/nested/dir/foo.txt"); + + Assertions.assertTrue(Files.exists(s3File), "Test resource file should exist in S3!"); + } + + @Test + @TestResources({ @TestResource(source = "classpath:/foo.txt"), + @TestResource(source = "classpath:/**/foo.txt") }) + void testMultipleWithPatterns() + throws Exception + { + String basedir = IdempotencePropertiesService.getInstance() + .getIdempotenceProperties() + .getBasedir(); + URI baseUri = URI.create(basedir); + FileSystem s3fs = S3FileSystemUtils.getOrCreateS3FileSystem(baseUri); + + Path s3File1 = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityTest-testMultipleWithPatterns/foo.txt"); + Assertions.assertTrue(Files.exists(s3File1), "Test resource file should exist in S3!"); + + Path s3File2 = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityTest-testMultipleWithPatterns/nested/dir/foo.txt"); + Assertions.assertTrue(Files.exists(s3File2), "Test resource file should exist in S3!"); + } + +} From 90422b0fdb454bcfa14b0992584bf8575f6b2950 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 02:05:57 +0000 Subject: [PATCH 4/4] Rename S3BasicFunctionalityTest to MinIO variant and add S3BasicFunctionalityAWSTest Co-authored-by: carlspring <1436265+carlspring@users.noreply.github.com> --- .../S3BasicFunctionalityAWSTest.java | 191 ++++++++++++++++++ ...ava => S3BasicFunctionalityMinIOTest.java} | 12 +- 2 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/S3BasicFunctionalityAWSTest.java rename idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/{S3BasicFunctionalityTest.java => S3BasicFunctionalityMinIOTest.java} (95%) diff --git a/idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/S3BasicFunctionalityAWSTest.java b/idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/S3BasicFunctionalityAWSTest.java new file mode 100644 index 0000000..d8074c4 --- /dev/null +++ b/idempotence-s3fs-nio/src/test/java/org/carlspring/testing/idempotence/S3BasicFunctionalityAWSTest.java @@ -0,0 +1,191 @@ +package org.carlspring.testing.idempotence; + +import org.carlspring.testing.idempotence.annotation.TestResource; +import org.carlspring.testing.idempotence.annotation.TestResources; +import org.carlspring.testing.idempotence.config.IdempotencePropertiesService; +import org.carlspring.testing.idempotence.config.S3fsNioIdempotenceProperties; +import org.carlspring.testing.idempotence.extension.S3TestResourceExtension; +import org.carlspring.testing.idempotence.util.S3FileSystemUtils; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.FileSystem; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Functional test verifying that {@link S3TestResourceExtension} correctly copies + * resources declared via {@link TestResources} into a real AWS S3 bucket and that those + * objects can subsequently be found via the NIO2 {@link FileSystem} API. + * + *This test is only enabled when the {@code AWS_ACCESS_KEY_ID} environment variable + * is set, indicating that explicit AWS credentials are available. The target S3 location + * is configured via the {@code org.carlspring.testing.idempotence.s3fs.basedir} system + * property (defaults to {@code s3:///idempotence-test-resources}).
+ * + *Prerequisites:
+ *A MinIO instance is started automatically via Testcontainers before the tests run. @@ -39,7 +39,7 @@ */ @Testcontainers(disabledWithoutDocker = true) @ExtendWith(S3TestResourceExtension.class) -class S3BasicFunctionalityTest +class S3BasicFunctionalityMinIOTest { static final String BUCKET = "idempotence-test-resources"; @@ -122,7 +122,7 @@ void testSingleFile() .getBasedir(); URI baseUri = URI.create(basedir); FileSystem s3fs = S3FileSystemUtils.getOrCreateS3FileSystem(baseUri); - Path s3File = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityTest-testSingleFile/foo.txt"); + Path s3File = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityMinIOTest-testSingleFile/foo.txt"); Assertions.assertTrue(Files.exists(s3File), "Test resource file should exist in S3!"); } @@ -137,7 +137,7 @@ void testWithPatterns() .getBasedir(); URI baseUri = URI.create(basedir); FileSystem s3fs = S3FileSystemUtils.getOrCreateS3FileSystem(baseUri); - Path s3File = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityTest-testWithPatterns/nested/dir/foo.txt"); + Path s3File = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityMinIOTest-testWithPatterns/nested/dir/foo.txt"); Assertions.assertTrue(Files.exists(s3File), "Test resource file should exist in S3!"); } @@ -154,10 +154,10 @@ void testMultipleWithPatterns() URI baseUri = URI.create(basedir); FileSystem s3fs = S3FileSystemUtils.getOrCreateS3FileSystem(baseUri); - Path s3File1 = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityTest-testMultipleWithPatterns/foo.txt"); + Path s3File1 = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityMinIOTest-testMultipleWithPatterns/foo.txt"); Assertions.assertTrue(Files.exists(s3File1), "Test resource file should exist in S3!"); - Path s3File2 = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityTest-testMultipleWithPatterns/nested/dir/foo.txt"); + Path s3File2 = s3fs.getPath(baseUri.getPath() + "/S3BasicFunctionalityMinIOTest-testMultipleWithPatterns/nested/dir/foo.txt"); Assertions.assertTrue(Files.exists(s3File2), "Test resource file should exist in S3!"); }