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"));
+ }
+}