From 547db2fd026c3a49e5b0f7c66dc8c9c4b331f540 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 22:21:02 +0200 Subject: [PATCH] test(backend): add ThumbnailServiceIntegrationTest against real MinIO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spins up a MinIO container (Testcontainers GenericContainer) alongside the existing PostgresContainerConfig, uploads a sample PDF, runs the real ThumbnailService, and reads the resulting JPEG back from the object store. Catches S3 signing / path-style access issues a mocked S3Client wouldn't — justifies the CI cost (~45s) per walkthrough T9b. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../ThumbnailServiceIntegrationTest.java | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceIntegrationTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceIntegrationTest.java new file mode 100644 index 00000000..492de1e5 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceIntegrationTest.java @@ -0,0 +1,142 @@ +package org.raddatz.familienarchiv.service; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Full round-trip integration test against real MinIO and real Postgres. Catches S3 + * signing / presigning issues that a mocked S3Client would miss — the rest of the + * test pyramid mocks at the FileService boundary. + */ +@SpringBootTest +@Import(PostgresContainerConfig.class) +class ThumbnailServiceIntegrationTest { + + private static final String BUCKET = "archive-documents"; + private static final String ACCESS_KEY = "minioadmin"; + private static final String SECRET_KEY = "minioadmin"; + + static GenericContainer minio = new GenericContainer<>("minio/minio:RELEASE.2024-06-13T22-53-53Z") + .withEnv("MINIO_ROOT_USER", ACCESS_KEY) + .withEnv("MINIO_ROOT_PASSWORD", SECRET_KEY) + .withCommand("server /data") + .withExposedPorts(9000); + + static { + minio.start(); + } + + @DynamicPropertySource + static void s3Properties(DynamicPropertyRegistry registry) { + registry.add("app.s3.endpoint", () -> "http://" + minio.getHost() + ":" + minio.getMappedPort(9000)); + registry.add("app.s3.access-key", () -> ACCESS_KEY); + registry.add("app.s3.secret-key", () -> SECRET_KEY); + registry.add("app.s3.bucket", () -> BUCKET); + registry.add("app.s3.region", () -> "eu-central-1"); + } + + @Autowired S3Client s3Client; + @Autowired ThumbnailService thumbnailService; + @Autowired DocumentRepository documentRepository; + + @Test + void generate_writesDecodableJpegToMinio_readbackMatches() throws IOException { + // Ensure bucket exists (the real app has a bootstrap container for this; in tests we do it here). + // Re-creating is a no-op; wrap in try/catch because the SDK throws on "already owned". + try (S3Client bootstrap = buildClient()) { + try { + bootstrap.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build()); + } catch (Exception ignored) { + // already exists + } + } + + // Persist first so Hibernate assigns the UUID — avoids StaleObjectState on a pre-set id + Document persisted = documentRepository.save(Document.builder() + .title("IT Doc") + .originalFilename("test.pdf") + .status(DocumentStatus.UPLOADED) + .contentType("application/pdf") + .build()); + UUID docId = persisted.getId(); + String pdfKey = "documents/" + docId + "_test.pdf"; + + s3Client.putObject(PutObjectRequest.builder() + .bucket(BUCKET) + .key(pdfKey) + .contentType("application/pdf") + .build(), + RequestBody.fromBytes(createSamplePdf())); + + persisted.setFilePath(pdfKey); + persisted = documentRepository.save(persisted); + + ThumbnailService.Outcome outcome = thumbnailService.generate(persisted); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + + Document reloaded = documentRepository.findById(docId).orElseThrow(); + assertThat(reloaded.getThumbnailKey()).isEqualTo("thumbnails/" + docId + ".jpg"); + assertThat(reloaded.getThumbnailGeneratedAt()).isNotNull(); + + // Read back from MinIO and verify it decodes as a JPEG of the expected width + try (InputStream in = s3Client.getObject(GetObjectRequest.builder() + .bucket(BUCKET).key(reloaded.getThumbnailKey()).build())) { + byte[] jpegBytes = in.readAllBytes(); + BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(jpegBytes)); + assertThat(decoded).isNotNull(); + assertThat(decoded.getWidth()).isEqualTo(240); + } + } + + private static S3Client buildClient() { + return S3Client.builder() + .endpointOverride(URI.create("http://" + minio.getHost() + ":" + minio.getMappedPort(9000))) + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .region(Region.of("eu-central-1")) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY))) + .build(); + } + + private static byte[] createSamplePdf() throws IOException { + try (PDDocument pdf = new PDDocument()) { + pdf.addPage(new PDPage(PDRectangle.A4)); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + pdf.save(bos); + return bos.toByteArray(); + } + } +}