test(backend): add ThumbnailServiceIntegrationTest against real MinIO

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-22 22:21:02 +02:00
parent f11a29504a
commit 547db2fd02

View File

@@ -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();
}
}
}