Dockerfile, API-Versionierung (/api/v1) und Tests für die REST-Schicht

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 11:54:18 +02:00
co-authored by Claude Fable 5
parent cf691649a1
commit 68acd867e5
6 changed files with 339 additions and 5 deletions
+3
View File
@@ -5,3 +5,6 @@ target/
.DS_Store
.vscode/
.idea/
# Lokale Umgebungsvariablen
.env
+20
View File
@@ -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"]
+2 -2
View File
@@ -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"
```
@@ -29,17 +29,17 @@ import java.util.stream.Collectors;
* REST-API zur Erzeugung und Prüfung rechtskonformer ZUGFeRD-E-Rechnungen.
*
* <pre>
* 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"
* </pre>
*/
@RestController
@RequestMapping("/api/invoices")
@RequestMapping("/api/v1/invoices")
public class InvoiceSigningController {
/** Response-Header mit dem Ergebnis der Mustang-Validierung (true/false). */
@@ -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<String, byte[]> 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<String, byte[]> readZip(byte[] zip) throws Exception {
Map<String, byte[]> 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;
}
}
@@ -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, "<report/>"));
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, "<report/>"));
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, "<report>ok</report>"));
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("<report>ok</report>"));
}
@Test
void validateReturns422ForInvalidInvoice() throws Exception {
when(validationService.validate(any(), any()))
.thenReturn(new ZugferdValidationService.ValidationResult(false, "<report>fehler</report>"));
mockMvc.perform(multipart("/api/v1/invoices/validate").file(pdfPart()))
.andExpect(status().isUnprocessableEntity())
.andExpect(header().string(InvoiceSigningController.VALIDATION_HEADER, "false"))
.andExpect(content().string("<report>fehler</report>"));
}
}