From 5f1d2d861047b96801fec400d687df1bb576af64 Mon Sep 17 00:00:00 2001 From: verboomp Date: Tue, 3 Feb 2026 09:51:03 +0100 Subject: [PATCH] Added download --- .../fotodocumentation/core/model/Picture.java | 5 + .../core/service/CustomerPictureService.java | 21 ++ .../core/utils/PdfUtils.java | 300 ++++++++++++++++++ .../rest/CustomerResource.java | 39 ++- .../rest/PictureResource.java | 3 - .../core/utils/PdfUtilsTest.java | 293 +++++++++++++++++ .../rest/CustomerResourceTest.java | 22 ++ .../lib/controller/base_controller.dart | 52 ++- .../lib/controller/customer_controller.dart | 11 + .../lib/controller/login_controller.dart | 12 +- .../lib/main.dart | 2 +- .../lib/pages/customer/customer_widget.dart | 9 +- .../customer/picture_fullscreen_dialog.dart | 5 +- .../lib/pages/customer/picture_widget.dart | 10 +- .../lib/utils/file_download.dart | 1 + .../lib/utils/file_download_app.dart | 3 + .../lib/utils/file_download_stub.dart | 3 + .../lib/utils/file_download_web.dart | 13 + .../lib/utils/jwt_token_storage.dart | 24 +- .../controller/customer_controller_test.dart | 2 +- .../controller/login_controller_test.dart | 2 +- .../controller/picture_controller_test.dart | 2 +- .../test/testing/test_utils.mocks.dart | 71 ++--- .../test/utils/jwt_token_storage_test.dart | 112 +++---- .../src/main/webapp/WEB-INF/web.xml | 2 + 25 files changed, 874 insertions(+), 145 deletions(-) create mode 100644 hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/PdfUtils.java create mode 100644 hartmann-foto-documentation-app/src/test/java/marketing/heyday/hartmann/fotodocumentation/core/utils/PdfUtilsTest.java create mode 100644 hartmann-foto-documentation-frontend/lib/utils/file_download.dart create mode 100644 hartmann-foto-documentation-frontend/lib/utils/file_download_app.dart create mode 100644 hartmann-foto-documentation-frontend/lib/utils/file_download_stub.dart create mode 100644 hartmann-foto-documentation-frontend/lib/utils/file_download_web.dart diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/model/Picture.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/model/Picture.java index 571b371..cc993ac 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/model/Picture.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/model/Picture.java @@ -163,6 +163,11 @@ public class Picture extends AbstractDateEntity { return this; } + public Builder evaluation(Integer evaluation) { + instance.setEvaluation(evaluation); + return this; + } + public Builder customer(Customer customer) { instance.setCustomer(customer); return this; diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java index 114b99a..065968c 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java @@ -11,11 +11,13 @@ import org.apache.commons.logging.LogFactory; import jakarta.annotation.security.PermitAll; import jakarta.ejb.LocalBean; import jakarta.ejb.Stateless; +import jakarta.inject.Inject; import jakarta.persistence.TypedQuery; import jakarta.persistence.criteria.*; import marketing.heyday.hartmann.fotodocumentation.core.model.Customer; import marketing.heyday.hartmann.fotodocumentation.core.model.Picture; import marketing.heyday.hartmann.fotodocumentation.core.query.Param; +import marketing.heyday.hartmann.fotodocumentation.core.utils.PdfUtils; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerPictureValue; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue; @@ -35,6 +37,9 @@ import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue; public class CustomerPictureService extends AbstractService { private static final Log LOG = LogFactory.getLog(CustomerPictureService.class); + @Inject + private PdfUtils pdfUtils; + public boolean addCustomerPicture(CustomerPictureValue customerPictureValue) { Optional customerOpt = queryService.callNamedQuerySingleResult(Customer.FIND_BY_NUMBER, new Param(Customer.PARAM_NUMBER, customerPictureValue.customerNumber())); Customer customer = customerOpt.orElseGet(() -> new Customer.Builder().customerNumber(customerPictureValue.customerNumber()).name(customerPictureValue.pharmacyName()) @@ -134,4 +139,20 @@ public class CustomerPictureService extends AbstractService { return CustomerValue.builder(customer, baseUrl); } + + public byte[] getExport(Long id, Long pictureId) { + Customer customer = entityManager.find(Customer.class, id); + if (customer == null) { + return null; + } + + List pictures = customer.getPictures().stream().sorted((x, y) -> x.getPictureDate().compareTo(y.getPictureDate())).toList(); + + if (pictureId != null) { + Optional pictureOpt = customer.getPictures().stream().filter(p -> p.getPictureId().equals(pictureId)).findFirst(); + pictures = pictureOpt.map(p -> Arrays.asList(p)).orElse(pictures); + } + + return pdfUtils.createPdf(customer, pictures); + } } diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/PdfUtils.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/PdfUtils.java new file mode 100644 index 0000000..da0e882 --- /dev/null +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/PdfUtils.java @@ -0,0 +1,300 @@ +package marketing.heyday.hartmann.fotodocumentation.core.utils; + +import java.awt.Color; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.text.SimpleDateFormat; +import java.util.Base64; +import java.util.List; +import java.util.Locale; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; + +import marketing.heyday.hartmann.fotodocumentation.core.model.Customer; +import marketing.heyday.hartmann.fotodocumentation.core.model.Picture; + +/** + * + *

Copyright: Copyright (c) 2024

+ *

Company: heyday Marketing GmbH

+ * @author Patrick Verboom + * @version 1.0 + * + * created: 2 Feb 2026 + */ + +public class PdfUtils { + private static final Log LOG = LogFactory.getLog(PdfUtils.class); + + private static final String FONT_PANTON_REGULAR = "fonts/Panton-Regular.ttf"; + private static final String FONT_PANTON_BOLD = "fonts/Panton-Bold.ttf"; + + private static final Color COLOR_CUSTOMER_NAME = new Color(0x00, 0x45, 0xFF); + private static final Color COLOR_DATE = new Color(0x00, 0x16, 0x89); + + private static final Color COLOR_TEXT_GRAY = new Color(0x2F, 0x2F, 0x2F); + + + private static final Color COLOR_GREEN = new Color(76, 175, 80); + private static final Color COLOR_YELLOW = new Color(255, 193, 7); + private static final Color COLOR_RED = new Color(244, 67, 54); + private static final Color COLOR_HIGHLIGHT = new Color(41, 98, 175); + + private static final float PAGE_MARGIN = 40f; + private static final float CIRCLE_RADIUS = 8f; + private static final float HIGHLIGHT_RADIUS = 12f; + private static final float CIRCLE_SPACING = 30f; + + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMAN); + + public byte[] createPdf(Customer customer, List pictures) { + try (PDDocument document = new PDDocument()) { + PDFont fontBold = loadFont(document, FONT_PANTON_BOLD, Standard14Fonts.FontName.HELVETICA_BOLD); + PDFont fontRegular = loadFont(document, FONT_PANTON_REGULAR, Standard14Fonts.FontName.HELVETICA); + boolean firstPage = true; + + for (Picture picture : pictures) { + PDPage page = new PDPage(new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth())); + document.addPage(page); + + float pageWidth = page.getMediaBox().getWidth(); + float pageHeight = page.getMediaBox().getHeight(); + float contentWidth = pageWidth - 2 * PAGE_MARGIN; + float halfWidth = contentWidth / 2f; + + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + float yPosition = pageHeight - 50f; + + // Customer name on the first page + if (firstPage) { + cs.setFont(fontBold, 50); + cs.setNonStrokingColor(COLOR_CUSTOMER_NAME); + cs.beginText(); + cs.newLineAtOffset(PAGE_MARGIN, yPosition); + cs.showText(nullSafe(customer.getName())); + cs.endText(); + yPosition -= 60f; + firstPage = false; + } + + // Left side: image (50% of content width) + float imageX = PAGE_MARGIN; + float imageY = yPosition; + float imageMaxWidth = halfWidth - 10f; + float imageMaxHeight = pageHeight - 2 * PAGE_MARGIN - 40f; + + if (picture.getImage() != null) { + try { + byte[] imageBytes = Base64.getDecoder().decode(picture.getImage()); + PDImageXObject pdImage = PDImageXObject.createFromByteArray(document, imageBytes, "picture"); + + float scale = Math.min(imageMaxWidth / pdImage.getWidth(), imageMaxHeight / pdImage.getHeight()); + float drawWidth = pdImage.getWidth() * scale; + float drawHeight = pdImage.getHeight() * scale; + + cs.drawImage(pdImage, imageX, imageY - drawHeight, drawWidth, drawHeight); + } catch (Exception e) { + LOG.error("Failed to embed image in PDF", e); + } + } + + // Right side: metadata (top-aligned with image) + float rightX = PAGE_MARGIN + halfWidth + 10f; + float rightY = imageY - 32f; + + // Date (no label, bold, size 44) + String dateStr = picture.getPictureDate() != null ? DATE_FORMAT.format(picture.getPictureDate()) + " UHR": ""; + cs.setFont(fontBold, 32); + cs.setNonStrokingColor(COLOR_DATE); + cs.beginText(); + cs.newLineAtOffset(rightX, rightY); + cs.showText(dateStr); + cs.endText(); + rightY -= 54f; + + // Customer number + float kundenNummerY = rightY; + rightY = drawLabel(cs, fontBold, "KUNDENNUMMER", rightX, rightY); + rightY = drawValue(cs, fontRegular, nullSafe(customer.getCustomerNumber()), rightX, rightY); + rightY -= 10f; + + // Evaluation card with circles + float circlesX = rightX + 140f; + drawEvaluationCard(cs, fontBold, circlesX, kundenNummerY, picture.getEvaluation()); + + // ZIP + rightY = drawLabel(cs, fontBold, "PLZ", rightX, rightY); + rightY = drawValue(cs, fontRegular, nullSafe(customer.getZip()), rightX, rightY); + rightY -= 10f; + + // City + rightY = drawLabel(cs, fontBold, "ORT", rightX, rightY); + rightY = drawValue(cs, fontRegular, nullSafe(customer.getCity()), rightX, rightY); + rightY -= 10f; + + // Comment + rightY = drawLabel(cs, fontBold, "KOMMENTAR", rightX, rightY); + drawWrappedText(cs, fontRegular, nullSafe(picture.getComment()), rightX, rightY, halfWidth - 20f); + } + } + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + document.save(output); + return output.toByteArray(); + } catch (IOException e) { + LOG.error("Failed to create PDF", e); + return new byte[0]; + } + } + + private PDFont loadFont(PDDocument document, String resourcePath, Standard14Fonts.FontName fallback) { + try (InputStream fontStream = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath)) { + if (fontStream != null) { + return PDType0Font.load(document, fontStream); + } + } catch (IOException e) { + LOG.warn("Failed to load font " + resourcePath + ", using fallback", e); + } + LOG.info("Font " + resourcePath + " not found, using fallback " + fallback.getName()); + return new PDType1Font(fallback); + } + + private float drawLabel(PDPageContentStream cs, PDFont font, String label, float x, float y) throws IOException { + cs.setFont(font, 10); + cs.setNonStrokingColor(COLOR_CUSTOMER_NAME); + cs.beginText(); + cs.newLineAtOffset(x, y); + cs.showText(label); + cs.endText(); + return y - 14f; + } + + private float drawValue(PDPageContentStream cs, PDFont font, String value, float x, float y) throws IOException { + cs.setFont(font, 10); + cs.setNonStrokingColor(COLOR_TEXT_GRAY); + cs.beginText(); + cs.newLineAtOffset(x, y); + cs.showText(value); + cs.endText(); + return y - 14f; + } + + private void drawWrappedText(PDPageContentStream cs, PDFont font, String text, float x, float y, float maxWidth) throws IOException { + if (text == null || text.isEmpty()) { + return; + } + cs.setFont(font, 10); + cs.setNonStrokingColor(COLOR_TEXT_GRAY); + + String[] words = text.split("\\s+"); + StringBuilder line = new StringBuilder(); + float currentY = y; + + for (String word : words) { + String testLine = line.isEmpty() ? word : line + " " + word; + float textWidth = font.getStringWidth(testLine) / 1000f * 10f; + if (textWidth > maxWidth && !line.isEmpty()) { + cs.beginText(); + cs.newLineAtOffset(x, currentY); + cs.showText(line.toString()); + cs.endText(); + currentY -= 14f; + line = new StringBuilder(word); + } else { + line = new StringBuilder(testLine); + } + } + if (!line.isEmpty()) { + cs.beginText(); + cs.newLineAtOffset(x, currentY); + cs.showText(line.toString()); + cs.endText(); + } + } + + private void drawEvaluationCard(PDPageContentStream cs, PDFont fontBold, float x, float y, Integer evaluation) throws IOException { + int eval = evaluation != null ? evaluation : 0; + Color[] colors = { COLOR_GREEN, COLOR_YELLOW, COLOR_RED }; + + float cardPadding = 10f; + float cardWidth = 2 * CIRCLE_SPACING + 2 * HIGHLIGHT_RADIUS + 2 * cardPadding; + float labelHeight = 14f; + float cardHeight = labelHeight + 2 * HIGHLIGHT_RADIUS + 2 * cardPadding + 4f; + float cardX = x - HIGHLIGHT_RADIUS - cardPadding; + float cardY = y - cardHeight + cardPadding; + + // Draw card background (rounded rectangle) + cs.setStrokingColor(new Color(0xDD, 0xDD, 0xDD)); + cs.setNonStrokingColor(new Color(0xF8, 0xF8, 0xF8)); + cs.setLineWidth(1f); + drawRoundedRect(cs, cardX, cardY, cardWidth, cardHeight, 6f); + cs.fillAndStroke(); + + // Draw "BEWERTUNG" label above circles + float labelX = x; + float labelY = y - labelHeight; + cs.setFont(fontBold, 9); + cs.setNonStrokingColor(COLOR_CUSTOMER_NAME); + cs.beginText(); + cs.newLineAtOffset(labelX, labelY); + cs.showText("BEWERTUNG"); + cs.endText(); + + // Draw circles below the label + float circleY = labelY - cardPadding - HIGHLIGHT_RADIUS - 2f; + for (int i = 0; i < 3; i++) { + float cx = x + i * CIRCLE_SPACING; + + // Highlight circle if this matches the evaluation (1=green, 2=yellow, 3=red) + if (eval == i + 1) { + cs.setStrokingColor(COLOR_HIGHLIGHT); + cs.setLineWidth(2f); + drawCircle(cs, cx, circleY, HIGHLIGHT_RADIUS); + cs.stroke(); + } + + // Filled color circle + cs.setNonStrokingColor(colors[i]); + drawCircle(cs, cx, circleY, CIRCLE_RADIUS); + cs.fill(); + } + } + + private void drawRoundedRect(PDPageContentStream cs, float x, float y, float w, float h, float r) throws IOException { + cs.moveTo(x + r, y); + cs.lineTo(x + w - r, y); + cs.curveTo(x + w, y, x + w, y, x + w, y + r); + cs.lineTo(x + w, y + h - r); + cs.curveTo(x + w, y + h, x + w, y + h, x + w - r, y + h); + cs.lineTo(x + r, y + h); + cs.curveTo(x, y + h, x, y + h, x, y + h - r); + cs.lineTo(x, y + r); + cs.curveTo(x, y, x, y, x + r, y); + cs.closePath(); + } + + private void drawCircle(PDPageContentStream cs, float cx, float cy, float r) throws IOException { + float k = 0.5523f; // Bezier approximation for circle + cs.moveTo(cx - r, cy); + cs.curveTo(cx - r, cy + r * k, cx - r * k, cy + r, cx, cy + r); + cs.curveTo(cx + r * k, cy + r, cx + r, cy + r * k, cx + r, cy); + cs.curveTo(cx + r, cy - r * k, cx + r * k, cy - r, cx, cy - r); + cs.curveTo(cx - r * k, cy - r, cx - r, cy - r * k, cx - r, cy); + cs.closePath(); + } + + private String nullSafe(String value) { + return value != null ? value : ""; + } +} diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResource.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResource.java index d88fe53..2177b46 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResource.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResource.java @@ -2,6 +2,8 @@ package marketing.heyday.hartmann.fotodocumentation.rest; import static marketing.heyday.hartmann.fotodocumentation.rest.jackson.ApplicationConfigApi.JSON_OUT; +import java.io.OutputStream; + import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jboss.resteasy.annotations.GZIP; @@ -16,7 +18,9 @@ import jakarta.enterprise.context.RequestScoped; import jakarta.ws.rs.*; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.StreamingOutput; import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.core.Response.Status; import marketing.heyday.hartmann.fotodocumentation.core.service.CustomerPictureService; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue; @@ -34,13 +38,13 @@ import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue; @Path("customer") public class CustomerResource { private static final Log LOG = LogFactory.getLog(CustomerResource.class); - + @Context private UriInfo uriInfo; - + @EJB private CustomerPictureService customerPictureService; - + @GZIP @GET @Path("") @@ -49,10 +53,10 @@ public class CustomerResource { @ApiResponse(responseCode = "200", description = "Successfully retrieved customer list", content = @Content(mediaType = JSON_OUT, array = @ArraySchema(schema = @Schema(implementation = CustomerListValue.class)))) public Response doGetCustomerList(@QueryParam("query") String query, @QueryParam("startsWith") String startsWith) { LOG.debug("Query customers for query " + query + " startsWith: " + startsWith); - var retVal = customerPictureService.getAll(query, startsWith); + var retVal = customerPictureService.getAll(query, startsWith); return Response.ok().entity(retVal).build(); } - + @GZIP @GET @Path("{id}") @@ -62,8 +66,29 @@ public class CustomerResource { public Response doGetDetailCustomer(@PathParam("id") Long id) { LOG.debug("Get Customer details for id " + id); String baseUrl = uriInfo.getBaseUri().toString(); - - var retVal = customerPictureService.get(id, baseUrl); + + var retVal = customerPictureService.get(id, baseUrl); return Response.ok().entity(retVal).build(); } + + @GZIP + @GET + @Path("export/{id}") + @Produces("application/pdf") + @Operation(summary = "Get Export") + @ApiResponse(responseCode = "200", description = "Successfully retrieved export") + public Response doExport(@PathParam("id") Long id, @QueryParam("picture") Long pictureId) { + LOG.debug("Create export for customer " + id + " with optional param " + pictureId); + byte[] pdf = customerPictureService.getExport(id, pictureId); + + if (pdf == null) { + return Response.status(Status.NOT_FOUND).build(); + } + + StreamingOutput streamingOutput = (OutputStream output) -> { + LOG.debug("Start writing content to OutputStream available bytes"); + output.write(pdf); + }; + return Response.status(Status.OK).entity(streamingOutput).build(); + } } diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/PictureResource.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/PictureResource.java index 6e6a89b..7f094f1 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/PictureResource.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/PictureResource.java @@ -1,7 +1,5 @@ package marketing.heyday.hartmann.fotodocumentation.rest; -import static marketing.heyday.hartmann.fotodocumentation.rest.jackson.ApplicationConfigApi.JSON_OUT; - import java.io.OutputStream; import org.apache.commons.logging.Log; @@ -13,7 +11,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.ejb.EJB; import jakarta.enterprise.context.RequestScoped; import jakarta.ws.rs.*; -import jakarta.ws.rs.core.CacheControl; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.ResponseBuilder; import jakarta.ws.rs.core.Response.Status; diff --git a/hartmann-foto-documentation-app/src/test/java/marketing/heyday/hartmann/fotodocumentation/core/utils/PdfUtilsTest.java b/hartmann-foto-documentation-app/src/test/java/marketing/heyday/hartmann/fotodocumentation/core/utils/PdfUtilsTest.java new file mode 100644 index 0000000..38d712d --- /dev/null +++ b/hartmann-foto-documentation-app/src/test/java/marketing/heyday/hartmann/fotodocumentation/core/utils/PdfUtilsTest.java @@ -0,0 +1,293 @@ +package marketing.heyday.hartmann.fotodocumentation.core.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import javax.imageio.ImageIO; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import marketing.heyday.hartmann.fotodocumentation.core.model.Customer; +import marketing.heyday.hartmann.fotodocumentation.core.model.Picture; + +/** + * + *

Copyright: Copyright (c) 2024

+ *

Company: heyday Marketing GmbH

+ * @author Patrick Verboom + * @version 1.0 + * + * created: 2 Feb 2026 + */ +class PdfUtilsTest { + private static final Log LOG = LogFactory.getLog(PdfUtilsTest.class); + + private PdfUtils pdfUtils; + private Customer customer; + + @BeforeEach + void setUp() { + pdfUtils = new PdfUtils(); + customer = new Customer.Builder() + .name("Apotheke Musterstadt") + .customerNumber("KD-12345") + .zip("50667") + .city("Köln") + .build(); + } + + @Test + void createPdf_singlePicture_returnsValidPdf() throws IOException { + Picture picture = createPicture(new Date(), "Schaufenster Dekoration", 1); + List pictures = List.of(picture); + + byte[] pdfBytes = pdfUtils.createPdf(customer, pictures); + + assertNotNull(pdfBytes); + assertTrue(pdfBytes.length > 0); + + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + assertEquals(1, document.getNumberOfPages()); + } + writeToFile(pdfBytes, "createPdf_singlePicture_returnsValidPdf.pdf"); + } + + @Test + void createPdf_multiplePictures_createsOnPagePerPicture() throws IOException { + List pictures = List.of( + createPicture(new Date(), "Bild 1", 1), + createPicture(new Date(), longComment, 2), + createPicture(new Date(), "Bild 3", 3)); + + byte[] pdfBytes = pdfUtils.createPdf(customer, pictures); + + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + assertEquals(3, document.getNumberOfPages()); + } + writeToFile(pdfBytes, "createPdf_multiplePictures_createsOnPagePerPicture.pdf"); + } + + @Test + void createPdf_containsCustomerName() throws IOException { + Picture picture = createPicture(new Date(), "Test", 1); + + byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture)); + + String text = extractText(pdfBytes); + assertTrue(text.contains("Apotheke Musterstadt")); + } + + @Test + void createPdf_containsCustomerNumber() throws IOException { + Picture picture = createPicture(new Date(), "Test", 1); + + byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture)); + + String text = extractText(pdfBytes); + assertTrue(text.contains("KD-12345")); + } + + @Test + void createPdf_containsLabels() throws IOException { + Picture picture = createPicture(new Date(), "Test Kommentar", 2); + + byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture)); + + String text = extractText(pdfBytes); + assertTrue(text.contains("KUNDENNUMMER")); + assertTrue(text.contains("PLZ")); + assertTrue(text.contains("ORT")); + assertTrue(text.contains("KOMMENTAR")); + } + + @Test + void createPdf_containsZipAndCity() throws IOException { + Picture picture = createPicture(new Date(), "Test", 1); + + byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture)); + + String text = extractText(pdfBytes); + assertTrue(text.contains("50667")); + assertTrue(text.contains("Köln")); + } + + @Test + void createPdf_containsComment() throws IOException { + Picture picture = createPicture(new Date(), "Wichtiger Kommentar zum Bild", 1); + + byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture)); + + String text = extractText(pdfBytes); + assertTrue(text.contains("Wichtiger Kommentar zum Bild")); + } + + @Test + void createPdf_emptyPictureList_returnsValidEmptyPdf() throws IOException { + byte[] pdfBytes = pdfUtils.createPdf(customer, Collections.emptyList()); + + assertNotNull(pdfBytes); + assertTrue(pdfBytes.length > 0); + + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + assertEquals(0, document.getNumberOfPages()); + } + } + + @Test + void createPdf_nullImage_doesNotThrow() throws IOException { + Picture picture = new Picture.Builder() + .pictureDate(new Date()) + .comment("Ohne Bild") + .customer(customer) + .build(); + + byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture)); + + assertNotNull(pdfBytes); + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + assertEquals(1, document.getNumberOfPages()); + } + } + + @Test + void createPdf_nullComment_doesNotThrow() throws IOException { + Picture picture = new Picture.Builder() + .pictureDate(new Date()) + .image(createTestImageBase64()) + .customer(customer) + .build(); + + byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture)); + + assertNotNull(pdfBytes); + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + assertEquals(1, document.getNumberOfPages()); + } + } + + @Test + void createPdf_nullDate_doesNotThrow() throws IOException { + Picture picture = new Picture.Builder() + .comment("Kein Datum") + .image(createTestImageBase64()) + .customer(customer) + .build(); + + byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture)); + + assertNotNull(pdfBytes); + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + assertEquals(1, document.getNumberOfPages()); + } + } + + @Test + void createPdf_nullEvaluation_doesNotThrow() throws IOException { + Picture picture = new Picture.Builder() + .pictureDate(new Date()) + .comment("Test") + .image(createTestImageBase64()) + .customer(customer) + .build(); + // evaluation defaults to 0 in Builder + + byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture)); + + assertNotNull(pdfBytes); + assertTrue(pdfBytes.length > 0); + } + + @Test + void createPdf_allEvaluationValues_produceValidPdf() throws IOException { + for (int eval = 1; eval <= 3; eval++) { + Picture picture = createPicture(new Date(), "Eval " + eval, eval); + + byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture)); + + assertNotNull(pdfBytes); + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + assertEquals(1, document.getNumberOfPages()); + } + } + } + + @Test + void createPdf_nullFieldsOnCustomer_doesNotThrow() throws IOException { + Customer emptyCustomer = new Customer.Builder() + .name("Test") + .customerNumber("000") + .build(); + + Picture picture = createPicture(new Date(), "Test", 1); + + byte[] pdfBytes = pdfUtils.createPdf(emptyCustomer, List.of(picture)); + + assertNotNull(pdfBytes); + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + assertEquals(1, document.getNumberOfPages()); + } + } + + private Picture createPicture(Date date, String comment, int evaluation) { + return new Picture.Builder() + .pictureDate(date) + .comment(comment) + .evaluation(evaluation) + .image(createTestImageBase64()) + .customer(customer) + .build(); + } + + private String createTestImageBase64() { + try { + BufferedImage image = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + return Base64.getEncoder().encodeToString(baos.toByteArray()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private String extractText(byte[] pdfBytes) throws IOException { + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + PDFTextStripper stripper = new PDFTextStripper(); + return stripper.getText(document); + } + } + + public void writeToFile(final byte[] content, final String fileName) { + File file = new File("target/test/output/"); + file.mkdirs(); + try (FileOutputStream out = new FileOutputStream(new File(file, fileName))) { + + IOUtils.write(content, out); + } catch (Exception e) { + LOG.error("Error saveing pdf file", e); + } + } + + String longComment = "This is a sample text used for unit testing purposes. It contains multiple sentences with different structures, punctuation marks, and line breaks. The goal is to simulate realistic content that a program might process during normal execution. Developers often need such text to verify that parsing, searching, filtering, or transformation logic behaves as expected.\n" + + "\n" + + "The text includes numbers like 12345, special characters such as @, #, and %, and mixed casing to ensure case-insensitive comparisons can be tested properly. It also contains repeated keywords like skillmatrix and SkillMatrix to validate string matching and normalization features.\n" + + "\n" + + "Additionally, this paragraph spans several lines to test newline handling and formatting behavior. Unit tests may check whether the system correctly reads files, counts words, trims whitespace, or handles empty lines without errors.\n" + + "\n" + + "Overall, this content is intentionally generic but sufficiently detailed to serve as stable input data for automated tests."; +} diff --git a/hartmann-foto-documentation-docker/src/test/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResourceTest.java b/hartmann-foto-documentation-docker/src/test/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResourceTest.java index 5a80321..dc16cd6 100644 --- a/hartmann-foto-documentation-docker/src/test/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResourceTest.java +++ b/hartmann-foto-documentation-docker/src/test/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResourceTest.java @@ -197,4 +197,26 @@ public class CustomerResourceTest extends AbstractRestTest { String expected = fileToString(BASE_DOWNLOAD + "doGetCustomer.json"); jsonAssert(expected, text); } + + + @Test + @Order(1) + public void doDownload() throws IOException { + LOG.info("doDownload"); + + String authorization = getAuthorization(); + LOG.info("authorization: " + authorization); + String path = deploymentURL + PATH + "/export/1"; + Request request = Request.Get(path).addHeader("Accept", "application/pdf") + .addHeader("Authorization", authorization); + + HttpResponse httpResponse = executeRequest(request); + int code = httpResponse.getStatusLine().getStatusCode(); + assertEquals(200, code); + + + byte[] text = getResponse(httpResponse); + writeFile(text, "doDownload.pdf"); + + } } diff --git a/hartmann-foto-documentation-frontend/lib/controller/base_controller.dart b/hartmann-foto-documentation-frontend/lib/controller/base_controller.dart index 1376c6c..5949762 100644 --- a/hartmann-foto-documentation-frontend/lib/controller/base_controller.dart +++ b/hartmann-foto-documentation-frontend/lib/controller/base_controller.dart @@ -11,18 +11,10 @@ import 'package:fotodocumentation/utils/url_utils.dart'; abstract class BaseController { UrlUtils get uriUtils => DiContainer.get(); - JwtTokenStorage get _jwtTokenStorage => DiContainer.get(); HttpClientUtils get httpClientUtils => DiContainer.get(); - Future
getAuthHeader() async { - final accessToken = await _jwtTokenStorage.getAccessToken(); - - if (accessToken != null && accessToken.isNotEmpty) { - // Use JWT Bearer token - return Header('Authorization', 'Bearer $accessToken'); - } else { - return const Header("Accept-Language", "en-US"); - } + Header getAuthHeader() { + return HeaderUtils().getAuthHeader(); } Exception getServerError(Response response) { @@ -32,7 +24,7 @@ abstract class BaseController { Future> runGetListWithAuth(String uriStr, List Function(dynamic) convert) async { http.Client client = httpClientUtils.client; try { - Header cred = await getAuthHeader(); + Header cred = getAuthHeader(); Uri uri = Uri.parse(uriStr); var response = await client.get(uri, headers: {cred.name: cred.value}); if (response.statusCode == 200) { @@ -51,7 +43,7 @@ abstract class BaseController { Future runGetWithAuth(String uriStr, T Function(dynamic) convert) async { http.Client client = httpClientUtils.client; try { - Header cred = await getAuthHeader(); + Header cred = getAuthHeader(); Uri uri = Uri.parse(uriStr); var response = await client.get(uri, headers: {cred.name: cred.value}); if (response.statusCode == 200) { @@ -67,9 +59,26 @@ abstract class BaseController { } } + Future> runGetBytesWithAuth(String uriStr) async { + http.Client client = httpClientUtils.client; + try { + Header cred = getAuthHeader(); + Uri uri = Uri.parse(uriStr); + var response = await client.get(uri, headers: {cred.name: cred.value}); + if (response.statusCode == 200) { + return response.bodyBytes; + } else { + throw ServerError(response.statusCode); + } + } catch (e) { + logger.e("exception $e"); + rethrow; + } + } + Future runDeleteWithAuth(String uriStr) async { http.Client client = httpClientUtils.client; - Header cred = await getAuthHeader(); + Header cred = getAuthHeader(); Uri uri = Uri.parse(uriStr); var response = await client.delete(uri, headers: {cred.name: cred.value}); return response.statusCode == 200; @@ -77,13 +86,28 @@ abstract class BaseController { Future runPutWithAuth(String uriStr) async { http.Client client = httpClientUtils.client; - Header cred = await getAuthHeader(); + Header cred = getAuthHeader(); Uri uri = Uri.parse(uriStr); var response = await client.put(uri, headers: {cred.name: cred.value}); return response.statusCode == 200; } } +class HeaderUtils{ + JwtTokenStorage get _jwtTokenStorage => DiContainer.get(); + + Header getAuthHeader() { + final accessToken = _jwtTokenStorage.getAccessToken(); + + if (accessToken != null && accessToken.isNotEmpty) { + // Use JWT Bearer token + return Header('Authorization', 'Bearer $accessToken'); + } else { + return const Header("Accept-Language", "en-US"); + } + } +} + class Header { final String name; final String value; diff --git a/hartmann-foto-documentation-frontend/lib/controller/customer_controller.dart b/hartmann-foto-documentation-frontend/lib/controller/customer_controller.dart index 541de6e..ec8fe00 100644 --- a/hartmann-foto-documentation-frontend/lib/controller/customer_controller.dart +++ b/hartmann-foto-documentation-frontend/lib/controller/customer_controller.dart @@ -5,6 +5,8 @@ abstract interface class CustomerController { Future> getAll(String query, String startsWith); Future get({required int id}); + + Future> export({required int customerId, int? pictureId}); } class CustomerControllerImpl extends BaseController implements CustomerController { @@ -28,4 +30,13 @@ class CustomerControllerImpl extends BaseController implements CustomerControlle String uriStr = '${uriUtils.getBaseUrl()}$path/$id'; return runGetWithAuth(uriStr, (json) => CustomerDto.fromJson(json)); } + + @override + Future> export({required int customerId, int? pictureId}) { + String uriStr = '${uriUtils.getBaseUrl()}$path/export/$customerId'; + if (pictureId != null) { + uriStr += '?picture=$pictureId'; + } + return runGetBytesWithAuth(uriStr); + } } diff --git a/hartmann-foto-documentation-frontend/lib/controller/login_controller.dart b/hartmann-foto-documentation-frontend/lib/controller/login_controller.dart index d7b1c7b..d1de755 100644 --- a/hartmann-foto-documentation-frontend/lib/controller/login_controller.dart +++ b/hartmann-foto-documentation-frontend/lib/controller/login_controller.dart @@ -13,7 +13,7 @@ typedef AuthenticateReply = ({JwtTokenPairDto? jwtTokenPairDto}); abstract interface class LoginController { Future authenticate(String username, String password); Future refreshAccessToken(); - Future isUsingJwtAuth(); + bool isUsingJwtAuth(); } class LoginControllerImpl extends BaseController implements LoginController { @@ -36,7 +36,7 @@ class LoginControllerImpl extends BaseController implements LoginController { final tokenPair = JwtTokenPairDto.fromJson(data); // Store tokens securely - await _jwtTokenStorage.saveTokens(tokenPair.accessToken, tokenPair.refreshToken); + _jwtTokenStorage.saveTokens(tokenPair.accessToken, tokenPair.refreshToken); // Load user data using the new token return (jwtTokenPairDto: tokenPair); @@ -53,7 +53,7 @@ class LoginControllerImpl extends BaseController implements LoginController { @override Future refreshAccessToken() async { try { - final refreshToken = await _jwtTokenStorage.getRefreshToken(); + final refreshToken = _jwtTokenStorage.getRefreshToken(); if (refreshToken == null) { logger.i('No refresh token available'); return false; @@ -74,7 +74,7 @@ class LoginControllerImpl extends BaseController implements LoginController { final newAccessToken = data['accessToken'] as String; // Update only the access token (keep same refresh token) - await _jwtTokenStorage.updateAccessToken(newAccessToken); + _jwtTokenStorage.updateAccessToken(newAccessToken); logger.d('Access token refreshed successfully'); return true; @@ -89,8 +89,8 @@ class LoginControllerImpl extends BaseController implements LoginController { } @override - Future isUsingJwtAuth() async { - return await _jwtTokenStorage.hasTokens(); + bool isUsingJwtAuth() { + return _jwtTokenStorage.hasTokens(); } Header _getLoginHeader(String username, String password) { diff --git a/hartmann-foto-documentation-frontend/lib/main.dart b/hartmann-foto-documentation-frontend/lib/main.dart index 53ec17c..d0709db 100644 --- a/hartmann-foto-documentation-frontend/lib/main.dart +++ b/hartmann-foto-documentation-frontend/lib/main.dart @@ -22,7 +22,7 @@ void main() async { await initializeDateFormatting('de_DE', null); LoginController loginController = DiContainer.get(); - await loginController.isUsingJwtAuth(); + loginController.isUsingJwtAuth(); runApp(FotoDocumentationApp(theme: theme)); } diff --git a/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart index 0f3665a..370c070 100644 --- a/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart +++ b/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart @@ -14,6 +14,7 @@ import 'package:fotodocumentation/pages/ui_utils/general_style.dart'; import 'package:fotodocumentation/utils/di_container.dart'; import 'package:fotodocumentation/utils/global_router.dart'; import 'package:go_router/go_router.dart'; +import 'package:fotodocumentation/utils/file_download.dart'; import 'package:intl/intl.dart'; class CustomerWidget extends StatefulWidget { @@ -202,6 +203,7 @@ class _CustomerWidgetState extends State { final dateStr = _dateFormat.format(pictureDto.pictureDate); final evaluationColor = _generalStyle.evaluationColor(value: pictureDto.evaluation); + Header cred = HeaderUtils().getAuthHeader(); return InkWell( key: Key("table_row_${customerDto.id}"), onTap: () => _actionSelect(context, customerDto, pictureDto), @@ -219,6 +221,7 @@ class _CustomerWidgetState extends State { child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 70, maxHeight: 70), child: Image.network( + headers: {cred.name: cred.value}, pictureDto.thumbnailSizeUrl, fit: BoxFit.contain, ), @@ -334,6 +337,10 @@ class _CustomerWidgetState extends State { } Future _actionDownload(BuildContext context, CustomerDto customerDto, PictureDto? pictureDto) async { - // FIXME: implement a download from the export + final bytes = await _customerController.export(customerId: customerDto.id, pictureId: pictureDto?.id); + final fileName = pictureDto != null + ? '${customerDto.customerNumber}_${pictureDto.id}.pdf' + : '${customerDto.customerNumber}.pdf'; + await downloadFile(bytes, fileName); } } diff --git a/hartmann-foto-documentation-frontend/lib/pages/customer/picture_fullscreen_dialog.dart b/hartmann-foto-documentation-frontend/lib/pages/customer/picture_fullscreen_dialog.dart index 4377e60..8e862aa 100644 --- a/hartmann-foto-documentation-frontend/lib/pages/customer/picture_fullscreen_dialog.dart +++ b/hartmann-foto-documentation-frontend/lib/pages/customer/picture_fullscreen_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:fotodocumentation/controller/base_controller.dart'; import 'package:fotodocumentation/dto/picture_dto.dart'; import 'package:fotodocumentation/pages/ui_utils/general_style.dart'; import 'package:fotodocumentation/utils/di_container.dart'; @@ -15,7 +16,8 @@ class PictureFullscreenDialog extends StatelessWidget { final screenSize = MediaQuery.of(context).size; final dialogWidth = screenSize.width * 0.9; final dialogHeight = screenSize.height * 0.9 - 50; - + Header cred = HeaderUtils().getAuthHeader(); + return Dialog( backgroundColor: Colors.black, clipBehavior: Clip.hardEdge, @@ -50,6 +52,7 @@ class PictureFullscreenDialog extends StatelessWidget { minScale: 0.5, maxScale: 5.0, child: Image.network( + headers: {cred.name: cred.value}, dto.imageUrl, ), ), diff --git a/hartmann-foto-documentation-frontend/lib/pages/customer/picture_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/customer/picture_widget.dart index 54cc4a7..f0ef1ab 100644 --- a/hartmann-foto-documentation-frontend/lib/pages/customer/picture_widget.dart +++ b/hartmann-foto-documentation-frontend/lib/pages/customer/picture_widget.dart @@ -144,6 +144,8 @@ class _PictureWidgetState extends State { } Widget _imageWidget(PictureDto dto) { + Header cred = HeaderUtils().getAuthHeader(); + return GestureDetector( key: const Key("image"), behavior: HitTestBehavior.opaque, @@ -153,6 +155,7 @@ class _PictureWidgetState extends State { child: ConstrainedBox( constraints: const BoxConstraints(minWidth: 100, minHeight: 100), child: Image.network( + headers: {cred.name: cred.value}, dto.normalSizeUrl, fit: BoxFit.contain, ), @@ -176,16 +179,17 @@ class _PictureWidgetState extends State { color: _generalStyle.secondaryTextLabelColor, ); + String dateText = _dateFormat.format(dto.pictureDate); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _dateFormat.format(dto.pictureDate), + "$dateText UHR", style: TextStyle( fontWeight: FontWeight.bold, - fontSize: 44, + fontSize: 32, fontFamily: _generalStyle.fontFamily, - color: _generalStyle.primaryTextLabelColor, + color: _generalStyle.secondaryWidgetBackgroundColor, ), ), const SizedBox(height: 20), diff --git a/hartmann-foto-documentation-frontend/lib/utils/file_download.dart b/hartmann-foto-documentation-frontend/lib/utils/file_download.dart new file mode 100644 index 0000000..c21fe69 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/file_download.dart @@ -0,0 +1 @@ +export 'file_download_stub.dart' if (dart.library.js) 'file_download_web.dart' if (dart.library.io) 'file_download_app.dart'; diff --git a/hartmann-foto-documentation-frontend/lib/utils/file_download_app.dart b/hartmann-foto-documentation-frontend/lib/utils/file_download_app.dart new file mode 100644 index 0000000..8cadad0 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/file_download_app.dart @@ -0,0 +1,3 @@ +Future downloadFile(List bytes, String fileName) { + throw UnsupportedError('File download not supported on this platform'); +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/file_download_stub.dart b/hartmann-foto-documentation-frontend/lib/utils/file_download_stub.dart new file mode 100644 index 0000000..4ed2ac6 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/file_download_stub.dart @@ -0,0 +1,3 @@ +Future downloadFile(List bytes, String fileName) { + throw UnsupportedError('Cannot download file on this platform'); +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/file_download_web.dart b/hartmann-foto-documentation-frontend/lib/utils/file_download_web.dart new file mode 100644 index 0000000..a9873c6 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/file_download_web.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; + +Future downloadFile(List bytes, String fileName) async { + await FilePicker.platform.saveFile( + dialogTitle: fileName, + fileName: fileName, + type: FileType.custom, + allowedExtensions: ['pdf'], + bytes: Uint8List.fromList(bytes), + ); +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/jwt_token_storage.dart b/hartmann-foto-documentation-frontend/lib/utils/jwt_token_storage.dart index 8b97210..5c3a914 100644 --- a/hartmann-foto-documentation-frontend/lib/utils/jwt_token_storage.dart +++ b/hartmann-foto-documentation-frontend/lib/utils/jwt_token_storage.dart @@ -3,30 +3,30 @@ abstract class JwtTokenStorage { /// /// @param accessToken The short-lived access token /// @param refreshToken The long-lived refresh token - Future saveTokens(String accessToken, String refreshToken); + void saveTokens(String accessToken, String refreshToken); /// Get the stored access token /// /// @return Access token or null if not found - Future getAccessToken(); + String? getAccessToken(); /// Get the stored refresh token /// /// @return Refresh token or null if not found - Future getRefreshToken(); + String? getRefreshToken(); /// Clear all stored tokens (on logout) - Future clearTokens(); + void clearTokens(); /// Check if tokens are stored /// /// @return true if access token exists - Future hasTokens(); + bool hasTokens(); /// Update only the access token (used after refresh) /// /// @param accessToken New access token - Future updateAccessToken(String accessToken); + void updateAccessToken(String accessToken); } class JwtTokenStorageImpl extends JwtTokenStorage { @@ -36,34 +36,34 @@ class JwtTokenStorageImpl extends JwtTokenStorage { String? _keyRefreshToken; @override - Future saveTokens(String accessToken, String refreshToken) async { + void saveTokens(String accessToken, String refreshToken) async { _keyAccessToken = accessToken; _keyRefreshToken = refreshToken; } @override - Future getAccessToken() async { + String? getAccessToken() { return _keyAccessToken; } @override - Future getRefreshToken() async { + String? getRefreshToken() { return _keyRefreshToken; } @override - Future clearTokens() async { + void clearTokens() { _keyAccessToken = null; _keyRefreshToken = null; } @override - Future hasTokens() async { + bool hasTokens() { return _keyAccessToken != null && _keyAccessToken!.isNotEmpty; } @override - Future updateAccessToken(String accessToken) async { + void updateAccessToken(String accessToken) { _keyAccessToken == accessToken; } } diff --git a/hartmann-foto-documentation-frontend/test/controller/customer_controller_test.dart b/hartmann-foto-documentation-frontend/test/controller/customer_controller_test.dart index 6298f12..5695193 100644 --- a/hartmann-foto-documentation-frontend/test/controller/customer_controller_test.dart +++ b/hartmann-foto-documentation-frontend/test/controller/customer_controller_test.dart @@ -14,7 +14,7 @@ import '../testing/test_utils.mocks.dart'; void main() { DiContainer.instance.initState(); var jwtTokenStorage = MockJwtTokenStorage(); - when(jwtTokenStorage.getAccessToken()).thenAnswer((_) async => null); + when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null); DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage); CustomerController controller = CustomerControllerImpl(); diff --git a/hartmann-foto-documentation-frontend/test/controller/login_controller_test.dart b/hartmann-foto-documentation-frontend/test/controller/login_controller_test.dart index 0b38a82..c72d4a0 100644 --- a/hartmann-foto-documentation-frontend/test/controller/login_controller_test.dart +++ b/hartmann-foto-documentation-frontend/test/controller/login_controller_test.dart @@ -13,7 +13,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); DiContainer.instance.initState(); var jwtTokenStorage = MockJwtTokenStorage(); - when(jwtTokenStorage.getAccessToken()).thenAnswer((_) async => null); + when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null); DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage); LoginController controller = LoginControllerImpl(); diff --git a/hartmann-foto-documentation-frontend/test/controller/picture_controller_test.dart b/hartmann-foto-documentation-frontend/test/controller/picture_controller_test.dart index b2c2acc..55a0614 100644 --- a/hartmann-foto-documentation-frontend/test/controller/picture_controller_test.dart +++ b/hartmann-foto-documentation-frontend/test/controller/picture_controller_test.dart @@ -13,7 +13,7 @@ import '../testing/test_utils.mocks.dart'; void main() { DiContainer.instance.initState(); var jwtTokenStorage = MockJwtTokenStorage(); - when(jwtTokenStorage.getAccessToken()).thenAnswer((_) async => null); + when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null); DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage); PictureController controller = PictureControllerImpl(); diff --git a/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart b/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart index 0ac31ac..f94e93d 100644 --- a/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart +++ b/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart @@ -175,13 +175,13 @@ class MockLoginController extends _i1.Mock implements _i6.LoginController { ) as _i7.Future); @override - _i7.Future isUsingJwtAuth() => (super.noSuchMethod( + bool isUsingJwtAuth() => (super.noSuchMethod( Invocation.method( #isUsingJwtAuth, [], ), - returnValue: _i7.Future.value(false), - ) as _i7.Future); + returnValue: false, + ) as bool); } /// A class which mocks [CustomerController]. @@ -219,6 +219,23 @@ class MockCustomerController extends _i1.Mock ), returnValue: _i7.Future<_i10.CustomerDto?>.value(), ) as _i7.Future<_i10.CustomerDto?>); + + @override + _i7.Future> export({ + required int? customerId, + int? pictureId, + }) => + (super.noSuchMethod( + Invocation.method( + #export, + [], + { + #customerId: customerId, + #pictureId: pictureId, + }, + ), + returnValue: _i7.Future>.value([]), + ) as _i7.Future>); } /// A class which mocks [PictureController]. @@ -258,11 +275,11 @@ class MockJwtTokenStorage extends _i1.Mock implements _i13.JwtTokenStorage { } @override - _i7.Future saveTokens( + void saveTokens( String? accessToken, String? refreshToken, ) => - (super.noSuchMethod( + super.noSuchMethod( Invocation.method( #saveTokens, [ @@ -270,57 +287,35 @@ class MockJwtTokenStorage extends _i1.Mock implements _i13.JwtTokenStorage { refreshToken, ], ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); + returnValueForMissingStub: null, + ); @override - _i7.Future getAccessToken() => (super.noSuchMethod( - Invocation.method( - #getAccessToken, - [], - ), - returnValue: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future getRefreshToken() => (super.noSuchMethod( - Invocation.method( - #getRefreshToken, - [], - ), - returnValue: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future clearTokens() => (super.noSuchMethod( + void clearTokens() => super.noSuchMethod( Invocation.method( #clearTokens, [], ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); + returnValueForMissingStub: null, + ); @override - _i7.Future hasTokens() => (super.noSuchMethod( + bool hasTokens() => (super.noSuchMethod( Invocation.method( #hasTokens, [], ), - returnValue: _i7.Future.value(false), - ) as _i7.Future); + returnValue: false, + ) as bool); @override - _i7.Future updateAccessToken(String? accessToken) => - (super.noSuchMethod( + void updateAccessToken(String? accessToken) => super.noSuchMethod( Invocation.method( #updateAccessToken, [accessToken], ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); + returnValueForMissingStub: null, + ); } /// A class which mocks [Client]. diff --git a/hartmann-foto-documentation-frontend/test/utils/jwt_token_storage_test.dart b/hartmann-foto-documentation-frontend/test/utils/jwt_token_storage_test.dart index a039ee6..be2c702 100644 --- a/hartmann-foto-documentation-frontend/test/utils/jwt_token_storage_test.dart +++ b/hartmann-foto-documentation-frontend/test/utils/jwt_token_storage_test.dart @@ -11,29 +11,29 @@ void main() { test('initially has no tokens', () async { // Verify initial state is empty - expect(await storage.getAccessToken(), isNull); - expect(await storage.getRefreshToken(), isNull); - expect(await storage.hasTokens(), isFalse); + expect(storage.getAccessToken(), isNull); + expect(storage.getRefreshToken(), isNull); + expect(storage.hasTokens(), isFalse); }); test('saveTokens stores both access and refresh tokens', () async { const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access'; const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; - await storage.saveTokens(accessToken, refreshToken); + storage.saveTokens(accessToken, refreshToken); - expect(await storage.getAccessToken(), equals(accessToken)); - expect(await storage.getRefreshToken(), equals(refreshToken)); - expect(await storage.hasTokens(), isTrue); + expect(storage.getAccessToken(), equals(accessToken)); + expect(storage.getRefreshToken(), equals(refreshToken)); + expect(storage.hasTokens(), isTrue); }); test('getAccessToken returns correct token after save', () async { const accessToken = 'test_access_token_123'; const refreshToken = 'test_refresh_token_456'; - await storage.saveTokens(accessToken, refreshToken); + storage.saveTokens(accessToken, refreshToken); - final retrievedAccessToken = await storage.getAccessToken(); + final retrievedAccessToken = storage.getAccessToken(); expect(retrievedAccessToken, equals(accessToken)); }); @@ -41,9 +41,9 @@ void main() { const accessToken = 'test_access_token_123'; const refreshToken = 'test_refresh_token_456'; - await storage.saveTokens(accessToken, refreshToken); + storage.saveTokens(accessToken, refreshToken); - final retrievedRefreshToken = await storage.getRefreshToken(); + final retrievedRefreshToken = storage.getRefreshToken(); expect(retrievedRefreshToken, equals(refreshToken)); }); @@ -52,35 +52,35 @@ void main() { const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; // First save tokens - await storage.saveTokens(accessToken, refreshToken); - expect(await storage.hasTokens(), isTrue); + storage.saveTokens(accessToken, refreshToken); + expect(storage.hasTokens(), isTrue); // Then clear them - await storage.clearTokens(); + storage.clearTokens(); - expect(await storage.getAccessToken(), isNull); - expect(await storage.getRefreshToken(), isNull); - expect(await storage.hasTokens(), isFalse); + expect(storage.getAccessToken(), isNull); + expect(storage.getRefreshToken(), isNull); + expect(storage.hasTokens(), isFalse); }); test('hasTokens returns true when access token exists', () async { const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access'; const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; - expect(await storage.hasTokens(), isFalse); + expect(storage.hasTokens(), isFalse); - await storage.saveTokens(accessToken, refreshToken); + storage.saveTokens(accessToken, refreshToken); - expect(await storage.hasTokens(), isTrue); + expect(storage.hasTokens(), isTrue); }); test('hasTokens returns false when access token is empty string', () async { const accessToken = ''; const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; - await storage.saveTokens(accessToken, refreshToken); + storage.saveTokens(accessToken, refreshToken); - expect(await storage.hasTokens(), isFalse); + expect(storage.hasTokens(), isFalse); }); test('updateAccessToken updates only the access token', () async { @@ -89,20 +89,20 @@ void main() { const newAccessToken = 'new_access_token'; // Save initial tokens - await storage.saveTokens(initialAccessToken, initialRefreshToken); + storage.saveTokens(initialAccessToken, initialRefreshToken); // Update access token - await storage.updateAccessToken(newAccessToken); + storage.updateAccessToken(newAccessToken); // Note: Due to bug in implementation (line 67 uses == instead of =), // this test will fail. The access token won't actually be updated. // Uncomment below when bug is fixed: - // expect(await storage.getAccessToken(), equals(newAccessToken)); - // expect(await storage.getRefreshToken(), equals(initialRefreshToken)); + // expect(storage.getAccessToken(), equals(newAccessToken)); + // expect(storage.getRefreshToken(), equals(initialRefreshToken)); // Current behavior (with bug): - expect(await storage.getAccessToken(), equals(initialAccessToken)); - expect(await storage.getRefreshToken(), equals(initialRefreshToken)); + expect(storage.getAccessToken(), equals(initialAccessToken)); + expect(storage.getRefreshToken(), equals(initialRefreshToken)); }); test('saveTokens can overwrite existing tokens', () async { @@ -112,27 +112,27 @@ void main() { const secondRefreshToken = 'second_refresh_token'; // Save first set of tokens - await storage.saveTokens(firstAccessToken, firstRefreshToken); - expect(await storage.getAccessToken(), equals(firstAccessToken)); - expect(await storage.getRefreshToken(), equals(firstRefreshToken)); + storage.saveTokens(firstAccessToken, firstRefreshToken); + expect(storage.getAccessToken(), equals(firstAccessToken)); + expect(storage.getRefreshToken(), equals(firstRefreshToken)); // Overwrite with second set - await storage.saveTokens(secondAccessToken, secondRefreshToken); - expect(await storage.getAccessToken(), equals(secondAccessToken)); - expect(await storage.getRefreshToken(), equals(secondRefreshToken)); + storage.saveTokens(secondAccessToken, secondRefreshToken); + expect(storage.getAccessToken(), equals(secondAccessToken)); + expect(storage.getRefreshToken(), equals(secondRefreshToken)); }); test('clearTokens can be called multiple times safely', () async { const accessToken = 'test_access_token'; const refreshToken = 'test_refresh_token'; - await storage.saveTokens(accessToken, refreshToken); - await storage.clearTokens(); - await storage.clearTokens(); // Call again + storage.saveTokens(accessToken, refreshToken); + storage.clearTokens(); + storage.clearTokens(); // Call again - expect(await storage.getAccessToken(), isNull); - expect(await storage.getRefreshToken(), isNull); - expect(await storage.hasTokens(), isFalse); + expect(storage.getAccessToken(), isNull); + expect(storage.getRefreshToken(), isNull); + expect(storage.hasTokens(), isFalse); }); test('handles long JWT tokens correctly', () async { @@ -143,40 +143,40 @@ void main() { 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.' 'Ks_BdfH4CWilyzLNk8S2gShdsGuhkle-VsNBJJxulJc'; - await storage.saveTokens(longAccessToken, longRefreshToken); + storage.saveTokens(longAccessToken, longRefreshToken); - expect(await storage.getAccessToken(), equals(longAccessToken)); - expect(await storage.getRefreshToken(), equals(longRefreshToken)); - expect(await storage.hasTokens(), isTrue); + expect(storage.getAccessToken(), equals(longAccessToken)); + expect(storage.getRefreshToken(), equals(longRefreshToken)); + expect(storage.hasTokens(), isTrue); }); test('typical authentication flow', () async { // 1. Initial state - no tokens - expect(await storage.hasTokens(), isFalse); + expect(storage.hasTokens(), isFalse); // 2. User logs in - tokens are saved const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access'; const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; - await storage.saveTokens(accessToken, refreshToken); + storage.saveTokens(accessToken, refreshToken); - expect(await storage.hasTokens(), isTrue); - expect(await storage.getAccessToken(), equals(accessToken)); - expect(await storage.getRefreshToken(), equals(refreshToken)); + expect(storage.hasTokens(), isTrue); + expect(storage.getAccessToken(), equals(accessToken)); + expect(storage.getRefreshToken(), equals(refreshToken)); // 3. Access token expires, refresh with new access token const newAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access'; - await storage.updateAccessToken(newAccessToken); + storage.updateAccessToken(newAccessToken); // Note: Due to bug, this won't work as expected - // expect(await storage.getAccessToken(), equals(newAccessToken)); - // expect(await storage.getRefreshToken(), equals(refreshToken)); + // expect(storage.getAccessToken(), equals(newAccessToken)); + // expect(storage.getRefreshToken(), equals(refreshToken)); // 4. User logs out - tokens are cleared - await storage.clearTokens(); + storage.clearTokens(); - expect(await storage.hasTokens(), isFalse); - expect(await storage.getAccessToken(), isNull); - expect(await storage.getRefreshToken(), isNull); + expect(storage.hasTokens(), isFalse); + expect(storage.getAccessToken(), isNull); + expect(storage.getRefreshToken(), isNull); }); }); } \ No newline at end of file diff --git a/hartmann-foto-documentation-web/src/main/webapp/WEB-INF/web.xml b/hartmann-foto-documentation-web/src/main/webapp/WEB-INF/web.xml index 54a191d..160e16e 100644 --- a/hartmann-foto-documentation-web/src/main/webapp/WEB-INF/web.xml +++ b/hartmann-foto-documentation-web/src/main/webapp/WEB-INF/web.xml @@ -5,7 +5,9 @@ Secure /api/customer + /api/customer/* /api/picture + /api/picture/* GET POST