From 68acd867e5776c36d42078fcd1eee87b49c0f989 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Fri, 17 Jul 2026 11:54:18 +0200 Subject: [PATCH] =?UTF-8?q?Dockerfile,=20API-Versionierung=20(/api/v1)=20u?= =?UTF-8?q?nd=20Tests=20f=C3=BCr=20die=20REST-Schicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile (Temurin 21, Zeitzone Berlin, MS Core Fonts für PDF-Renderer) - API-Pfade auf /api/v1/invoices versioniert (Controller, Javadoc, README) - Web-Schicht-Tests für InvoiceSigningController (MockMvc, gemockte Services) - End-to-End-Integrationstest: sign -> ZIP -> erneute Validierung - .env in .gitignore aufgenommen Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + Dockerfile | 20 +++ README.md | 4 +- .../api/InvoiceSigningController.java | 6 +- .../api/InvoiceSigningApiIntegrationTest.java | 148 ++++++++++++++++ .../api/InvoiceSigningControllerTest.java | 163 ++++++++++++++++++ 6 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 Dockerfile create mode 100644 src/test/java/de/assecutor/pdfservice/api/InvoiceSigningApiIntegrationTest.java create mode 100644 src/test/java/de/assecutor/pdfservice/api/InvoiceSigningControllerTest.java diff --git a/.gitignore b/.gitignore index 547c1f8..432168f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ target/ .DS_Store .vscode/ .idea/ + +# Lokale Umgebungsvariablen +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58bdf17 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM eclipse-temurin:21-jre + +ARG JAR_FILE=target/*.jar + +# Zeitzone auf Berlin setzen und 24h-Format konfigurieren +ENV TZ=Europe/Berlin +ENV LC_TIME=de_DE.UTF-8 +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Schriften für den PDF-Renderer: Microsoft Core Fonts (Arial, Times New Roman, +# Courier New, Verdana) sowie DejaVu als Fallback. Die EULA der MS-Fonts wird +# vorab bestätigt; der Installer lädt die Fonts beim Image-Build herunter. +RUN echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" | debconf-set-selections \ + && apt-get update \ + && apt-get install -y --no-install-recommends fontconfig fonts-dejavu-core ttf-mscorefonts-installer \ + && rm -rf /var/lib/apt/lists/* + +COPY ${JAR_FILE} app.jar +EXPOSE 8084 +ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"] diff --git a/README.md b/README.md index f2e6545..b5d5ad5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Der Service läuft standardmäßig auf Port `8084` (überschreibbar per `SERVER_ ### PDF in ZUGFeRD-Rechnung umwandeln ```bash -curl -X POST http://localhost:8084/api/invoices/sign \ +curl -X POST http://localhost:8084/api/v1/invoices/sign \ -F "file=@rechnung.pdf" \ -F "metadata=@metadata.json;type=application/json" \ -o rechnung-zugferd.zip @@ -33,7 +33,7 @@ der Header `X-Zugferd-Valid` enthält das Prüfergebnis. ### ZUGFeRD-Rechnung validieren ```bash -curl -X POST http://localhost:8084/api/invoices/validate \ +curl -X POST http://localhost:8084/api/v1/invoices/validate \ -F "file=@rechnung-zugferd.pdf" ``` diff --git a/src/main/java/de/assecutor/pdfservice/api/InvoiceSigningController.java b/src/main/java/de/assecutor/pdfservice/api/InvoiceSigningController.java index 6245d89..70a9959 100644 --- a/src/main/java/de/assecutor/pdfservice/api/InvoiceSigningController.java +++ b/src/main/java/de/assecutor/pdfservice/api/InvoiceSigningController.java @@ -29,17 +29,17 @@ import java.util.stream.Collectors; * REST-API zur Erzeugung und Prüfung rechtskonformer ZUGFeRD-E-Rechnungen. * *
- * curl -X POST http://localhost:8083/api/invoices/sign \
+ * curl -X POST http://localhost:8084/api/v1/invoices/sign \
  *   -F "file=@rechnung.pdf" \
  *   -F "metadata=@metadata.json;type=application/json" \
  *   -o rechnung-zugferd.zip
  *
- * curl -X POST http://localhost:8083/api/invoices/validate \
+ * curl -X POST http://localhost:8084/api/v1/invoices/validate \
  *   -F "file=@rechnung-zugferd.pdf"
  * 
*/ @RestController -@RequestMapping("/api/invoices") +@RequestMapping("/api/v1/invoices") public class InvoiceSigningController { /** Response-Header mit dem Ergebnis der Mustang-Validierung (true/false). */ diff --git a/src/test/java/de/assecutor/pdfservice/api/InvoiceSigningApiIntegrationTest.java b/src/test/java/de/assecutor/pdfservice/api/InvoiceSigningApiIntegrationTest.java new file mode 100644 index 0000000..2b1f08c --- /dev/null +++ b/src/test/java/de/assecutor/pdfservice/api/InvoiceSigningApiIntegrationTest.java @@ -0,0 +1,148 @@ +package de.assecutor.pdfservice.api; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * End-to-End-Test über die echte Service-Kette (kein Mocking): ein Rechnungs-PDF + * wird über /sign in eine ZUGFeRD-Rechnung umgewandelt und das erzeugte PDF + * anschließend über /validate erneut geprüft. + */ +@SpringBootTest +@AutoConfigureMockMvc +class InvoiceSigningApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + private static final String METADATA = """ + { + "invoiceNumber": "RE-2026-4711", + "issueDate": "2026-07-08", + "deliveryDate": "2026-07-01", + "dueDate": "2026-07-22", + "currency": "EUR", + "paymentTerms": "Zahlbar innerhalb von 14 Tagen ohne Abzug.", + "buyerReference": "KR-2026-042", + "iban": "DE75512108001245126199", + "sender": { + "name": "Assecutor Data Service GmbH", + "street": "Gerhart-Hauptmann-Weg 14", + "zip": "21502", + "city": "Geesthacht", + "countryCode": "DE", + "vatId": "DE261094748", + "email": "rechnung@example.com", + "phone": "+49 40 18 123 771 0" + }, + "recipient": { + "name": "Kunde AG", + "street": "Beispielweg 2", + "zip": "10115", + "city": "Berlin", + "countryCode": "DE", + "email": "einkauf@example.com" + }, + "items": [ + {"description": "Beratungsleistung", "quantity": 1, "unitPriceNet": 1500.00, "vatPercent": 19} + ] + } + """; + + @Test + void signsAndRevalidatesInvoiceEndToEnd() throws Exception { + MockMultipartFile pdf = new MockMultipartFile("file", "rechnung.pdf", + MediaType.APPLICATION_PDF_VALUE, createSamplePdf()); + MockMultipartFile metadata = new MockMultipartFile("metadata", "metadata.json", + MediaType.APPLICATION_JSON_VALUE, METADATA.getBytes(StandardCharsets.UTF_8)); + + // 1. PDF + Metadaten -> ZUGFeRD-ZIP + MvcResult signResult = mockMvc.perform(multipart("/api/v1/invoices/sign").file(pdf).file(metadata)) + .andExpect(status().isOk()) + .andExpect(header().string(InvoiceSigningController.VALIDATION_HEADER, "true")) + .andExpect(header().string("Content-Type", "application/zip")) + .andReturn(); + + Map entries = readZip(signResult.getResponse().getContentAsByteArray()); + assertEquals(3, entries.size(), "ZIP muss PDF, Factur-X-XML und Prüfbericht enthalten: " + entries.keySet()); + byte[] zugferdPdf = entries.get("RE-2026-4711-zugferd.pdf"); + assertNotNull(zugferdPdf, "ZUGFeRD-PDF fehlt im ZIP: " + entries.keySet()); + assertNotNull(entries.get("factur-x.xml"), "Factur-X-XML fehlt im ZIP"); + assertNotNull(entries.get("validation-report.xml"), "Prüfbericht fehlt im ZIP"); + + String facturX = new String(entries.get("factur-x.xml"), StandardCharsets.UTF_8); + assertTrue(facturX.contains("RE-2026-4711"), "Rechnungsnummer fehlt im Factur-X-XML"); + + // 2. Das erzeugte ZUGFeRD-PDF muss die Validierung erneut bestehen + MockMultipartFile signedPdf = new MockMultipartFile("file", "RE-2026-4711-zugferd.pdf", + MediaType.APPLICATION_PDF_VALUE, zugferdPdf); + mockMvc.perform(multipart("/api/v1/invoices/validate").file(signedPdf)) + .andExpect(status().isOk()) + .andExpect(header().string(InvoiceSigningController.VALIDATION_HEADER, "true")); + } + + @Test + void validateRejectsPlainPdfWithoutZugferdXml() throws Exception { + MockMultipartFile pdf = new MockMultipartFile("file", "rechnung.pdf", + MediaType.APPLICATION_PDF_VALUE, createSamplePdf()); + + mockMvc.perform(multipart("/api/v1/invoices/validate").file(pdf)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(header().string(InvoiceSigningController.VALIDATION_HEADER, "false")); + } + + private static byte[] createSamplePdf() throws Exception { + try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { + PDPage page = new PDPage(); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + // PDF/A verlangt eingebettete Schriften; LiberationSans liegt pdfbox bei + PDType0Font font = PDType0Font.load(doc, PDDocument.class.getResourceAsStream( + "/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"), true); + cs.beginText(); + cs.setFont(font, 12); + cs.newLineAtOffset(50, 700); + cs.showText("Rechnung RE-2026-4711"); + cs.endText(); + } + doc.save(out); + return out.toByteArray(); + } + } + + private static Map readZip(byte[] zip) throws Exception { + Map entries = new HashMap<>(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entries.put(entry.getName(), zis.readAllBytes()); + } + } + return entries; + } +} diff --git a/src/test/java/de/assecutor/pdfservice/api/InvoiceSigningControllerTest.java b/src/test/java/de/assecutor/pdfservice/api/InvoiceSigningControllerTest.java new file mode 100644 index 0000000..7ee8fab --- /dev/null +++ b/src/test/java/de/assecutor/pdfservice/api/InvoiceSigningControllerTest.java @@ -0,0 +1,163 @@ +package de.assecutor.pdfservice.api; + +import de.assecutor.pdfservice.zugferd.ZugferdConversionException; +import de.assecutor.pdfservice.zugferd.ZugferdResult; +import de.assecutor.pdfservice.zugferd.ZugferdService; +import de.assecutor.pdfservice.zugferd.ZugferdValidationService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Web-Schicht-Tests für {@link InvoiceSigningController}: HTTP-Statuscodes, + * Header und Fehlerbehandlung — die ZUGFeRD-Verarbeitung selbst ist gemockt + * (fachliche Tests dazu in ZugferdServiceTest). + */ +@WebMvcTest(InvoiceSigningController.class) +class InvoiceSigningControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ZugferdService zugferdService; + + @MockitoBean + private ZugferdValidationService validationService; + + private static final String VALID_METADATA = """ + { + "invoiceNumber": "RE-2026-0815", + "issueDate": "2026-07-08", + "sender": { + "name": "Assecutor Data Service GmbH", + "street": "Gerhart-Hauptmann-Weg 14", + "zip": "21502", + "city": "Geesthacht", + "countryCode": "DE", + "vatId": "DE261094748", + "email": "rechnung@example.com", + "phone": "+49 40 18 123 771 0" + }, + "recipient": { + "name": "Kunde AG", + "street": "Beispielweg 2", + "zip": "10115", + "city": "Berlin", + "countryCode": "DE", + "email": "einkauf@example.com" + }, + "items": [ + {"description": "Beratungsleistung", "quantity": 1, "unitPriceNet": 1500.00, "vatPercent": 19} + ] + } + """; + + private static MockMultipartFile pdfPart() { + return new MockMultipartFile("file", "rechnung.pdf", MediaType.APPLICATION_PDF_VALUE, + "%PDF-1.4 dummy".getBytes(StandardCharsets.UTF_8)); + } + + private static MockMultipartFile metadataPart(String json) { + return new MockMultipartFile("metadata", "metadata.json", MediaType.APPLICATION_JSON_VALUE, + json.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void signReturnsZipWithHeadersWhenInvoiceIsValid() throws Exception { + byte[] zip = {0x50, 0x4B, 0x03, 0x04}; + when(zugferdService.createZugferdZip(any(), any())) + .thenReturn(new ZugferdResult(zip, "RE-2026-0815.zip", true, "")); + + mockMvc.perform(multipart("/api/v1/invoices/sign").file(pdfPart()).file(metadataPart(VALID_METADATA))) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")) + .andExpect(header().string(InvoiceSigningController.VALIDATION_HEADER, "true")) + .andExpect(header().string("Content-Disposition", containsString("RE-2026-0815.zip"))) + .andExpect(content().bytes(zip)); + } + + @Test + void signReturns422WhenInvoiceIsInvalidButStillDeliversZip() throws Exception { + byte[] zip = {0x50, 0x4B, 0x03, 0x04}; + when(zugferdService.createZugferdZip(any(), any())) + .thenReturn(new ZugferdResult(zip, "RE-2026-0815.zip", false, "")); + + mockMvc.perform(multipart("/api/v1/invoices/sign").file(pdfPart()).file(metadataPart(VALID_METADATA))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(header().string(InvoiceSigningController.VALIDATION_HEADER, "false")) + .andExpect(content().bytes(zip)); + } + + @Test + void signReturns400ForMalformedMetadataJson() throws Exception { + mockMvc.perform(multipart("/api/v1/invoices/sign").file(pdfPart()).file(metadataPart("kein json {"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", containsString("kein gültiges JSON"))); + } + + @Test + void signReturns400ForIncompleteMetadata() throws Exception { + String withoutInvoiceNumber = VALID_METADATA.replace("\"invoiceNumber\": \"RE-2026-0815\",", ""); + + mockMvc.perform(multipart("/api/v1/invoices/sign").file(pdfPart()).file(metadataPart(withoutInvoiceNumber))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", containsString("Metadaten unvollständig"))) + .andExpect(jsonPath("$.error", containsString("invoiceNumber"))); + } + + @Test + void signReturns400WhenConversionFails() throws Exception { + when(zugferdService.createZugferdZip(any(), any())) + .thenThrow(new ZugferdConversionException("Die hochgeladene Datei ist kein gültiges PDF.")); + + mockMvc.perform(multipart("/api/v1/invoices/sign").file(pdfPart()).file(metadataPart(VALID_METADATA))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", containsString("kein gültiges PDF"))); + } + + @Test + void signReturns400WhenMetadataPartIsMissing() throws Exception { + mockMvc.perform(multipart("/api/v1/invoices/sign").file(pdfPart())) + .andExpect(status().isBadRequest()); + } + + @Test + void validateReturnsReportXmlWithHeaderForValidInvoice() throws Exception { + when(validationService.validate(any(), eq("rechnung.pdf"))) + .thenReturn(new ZugferdValidationService.ValidationResult(true, "ok")); + + mockMvc.perform(multipart("/api/v1/invoices/validate").file(pdfPart())) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_XML)) + .andExpect(header().string(InvoiceSigningController.VALIDATION_HEADER, "true")) + .andExpect(content().string("ok")); + } + + @Test + void validateReturns422ForInvalidInvoice() throws Exception { + when(validationService.validate(any(), any())) + .thenReturn(new ZugferdValidationService.ValidationResult(false, "fehler")); + + mockMvc.perform(multipart("/api/v1/invoices/validate").file(pdfPart())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(header().string(InvoiceSigningController.VALIDATION_HEADER, "false")) + .andExpect(content().string("fehler")); + } +}