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:
@@ -5,3 +5,6 @@ target/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Lokale Umgebungsvariablen
|
||||
.env
|
||||
|
||||
+20
@@ -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"]
|
||||
@@ -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>"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user