Added download

This commit is contained in:
verboomp
2026-02-03 09:51:03 +01:00
parent f9ca668b39
commit 5f1d2d8610
25 changed files with 874 additions and 145 deletions

View File

@@ -163,6 +163,11 @@ public class Picture extends AbstractDateEntity {
return this; return this;
} }
public Builder evaluation(Integer evaluation) {
instance.setEvaluation(evaluation);
return this;
}
public Builder customer(Customer customer) { public Builder customer(Customer customer) {
instance.setCustomer(customer); instance.setCustomer(customer);
return this; return this;

View File

@@ -11,11 +11,13 @@ import org.apache.commons.logging.LogFactory;
import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.PermitAll;
import jakarta.ejb.LocalBean; import jakarta.ejb.LocalBean;
import jakarta.ejb.Stateless; import jakarta.ejb.Stateless;
import jakarta.inject.Inject;
import jakarta.persistence.TypedQuery; import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.*; import jakarta.persistence.criteria.*;
import marketing.heyday.hartmann.fotodocumentation.core.model.Customer; import marketing.heyday.hartmann.fotodocumentation.core.model.Customer;
import marketing.heyday.hartmann.fotodocumentation.core.model.Picture; import marketing.heyday.hartmann.fotodocumentation.core.model.Picture;
import marketing.heyday.hartmann.fotodocumentation.core.query.Param; 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.CustomerListValue;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerPictureValue; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerPictureValue;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue; 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 { public class CustomerPictureService extends AbstractService {
private static final Log LOG = LogFactory.getLog(CustomerPictureService.class); private static final Log LOG = LogFactory.getLog(CustomerPictureService.class);
@Inject
private PdfUtils pdfUtils;
public boolean addCustomerPicture(CustomerPictureValue customerPictureValue) { public boolean addCustomerPicture(CustomerPictureValue customerPictureValue) {
Optional<Customer> customerOpt = queryService.callNamedQuerySingleResult(Customer.FIND_BY_NUMBER, new Param(Customer.PARAM_NUMBER, customerPictureValue.customerNumber())); Optional<Customer> 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()) 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); 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<Picture> pictures = customer.getPictures().stream().sorted((x, y) -> x.getPictureDate().compareTo(y.getPictureDate())).toList();
if (pictureId != null) {
Optional<Picture> 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);
}
} }

View File

@@ -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;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @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<Picture> 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 : "";
}
}

View File

@@ -2,6 +2,8 @@ package marketing.heyday.hartmann.fotodocumentation.rest;
import static marketing.heyday.hartmann.fotodocumentation.rest.jackson.ApplicationConfigApi.JSON_OUT; 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.Log;
import org.apache.commons.logging.LogFactory; import org.apache.commons.logging.LogFactory;
import org.jboss.resteasy.annotations.GZIP; import org.jboss.resteasy.annotations.GZIP;
@@ -16,7 +18,9 @@ import jakarta.enterprise.context.RequestScoped;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import jakarta.ws.rs.core.UriInfo; 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.core.service.CustomerPictureService;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue;
@@ -49,7 +53,7 @@ public class CustomerResource {
@ApiResponse(responseCode = "200", description = "Successfully retrieved customer list", content = @Content(mediaType = JSON_OUT, array = @ArraySchema(schema = @Schema(implementation = CustomerListValue.class)))) @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) { public Response doGetCustomerList(@QueryParam("query") String query, @QueryParam("startsWith") String startsWith) {
LOG.debug("Query customers for query " + query + " startsWith: " + 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(); return Response.ok().entity(retVal).build();
} }
@@ -63,7 +67,28 @@ public class CustomerResource {
LOG.debug("Get Customer details for id " + id); LOG.debug("Get Customer details for id " + id);
String baseUrl = uriInfo.getBaseUri().toString(); String baseUrl = uriInfo.getBaseUri().toString();
var retVal = customerPictureService.get(id, baseUrl); var retVal = customerPictureService.get(id, baseUrl);
return Response.ok().entity(retVal).build(); 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();
}
} }

View File

@@ -1,7 +1,5 @@
package marketing.heyday.hartmann.fotodocumentation.rest; package marketing.heyday.hartmann.fotodocumentation.rest;
import static marketing.heyday.hartmann.fotodocumentation.rest.jackson.ApplicationConfigApi.JSON_OUT;
import java.io.OutputStream; import java.io.OutputStream;
import org.apache.commons.logging.Log; import org.apache.commons.logging.Log;
@@ -13,7 +11,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.ejb.EJB; import jakarta.ejb.EJB;
import jakarta.enterprise.context.RequestScoped; import jakarta.enterprise.context.RequestScoped;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.CacheControl;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.ResponseBuilder; import jakarta.ws.rs.core.Response.ResponseBuilder;
import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.Response.Status;

View File

@@ -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;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @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<Picture> 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<Picture> 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.";
}

View File

@@ -197,4 +197,26 @@ public class CustomerResourceTest extends AbstractRestTest {
String expected = fileToString(BASE_DOWNLOAD + "doGetCustomer.json"); String expected = fileToString(BASE_DOWNLOAD + "doGetCustomer.json");
jsonAssert(expected, text); 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");
}
} }

View File

@@ -11,18 +11,10 @@ import 'package:fotodocumentation/utils/url_utils.dart';
abstract class BaseController { abstract class BaseController {
UrlUtils get uriUtils => DiContainer.get(); UrlUtils get uriUtils => DiContainer.get();
JwtTokenStorage get _jwtTokenStorage => DiContainer.get();
HttpClientUtils get httpClientUtils => DiContainer.get(); HttpClientUtils get httpClientUtils => DiContainer.get();
Future<Header> getAuthHeader() async { Header getAuthHeader() {
final accessToken = await _jwtTokenStorage.getAccessToken(); return HeaderUtils().getAuthHeader();
if (accessToken != null && accessToken.isNotEmpty) {
// Use JWT Bearer token
return Header('Authorization', 'Bearer $accessToken');
} else {
return const Header("Accept-Language", "en-US");
}
} }
Exception getServerError(Response response) { Exception getServerError(Response response) {
@@ -32,7 +24,7 @@ abstract class BaseController {
Future<List<T>> runGetListWithAuth<T>(String uriStr, List<T> Function(dynamic) convert) async { Future<List<T>> runGetListWithAuth<T>(String uriStr, List<T> Function(dynamic) convert) async {
http.Client client = httpClientUtils.client; http.Client client = httpClientUtils.client;
try { try {
Header cred = await getAuthHeader(); Header cred = getAuthHeader();
Uri uri = Uri.parse(uriStr); Uri uri = Uri.parse(uriStr);
var response = await client.get(uri, headers: {cred.name: cred.value}); var response = await client.get(uri, headers: {cred.name: cred.value});
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -51,7 +43,7 @@ abstract class BaseController {
Future<T?> runGetWithAuth<T>(String uriStr, T Function(dynamic) convert) async { Future<T?> runGetWithAuth<T>(String uriStr, T Function(dynamic) convert) async {
http.Client client = httpClientUtils.client; http.Client client = httpClientUtils.client;
try { try {
Header cred = await getAuthHeader(); Header cred = getAuthHeader();
Uri uri = Uri.parse(uriStr); Uri uri = Uri.parse(uriStr);
var response = await client.get(uri, headers: {cred.name: cred.value}); var response = await client.get(uri, headers: {cred.name: cred.value});
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -67,9 +59,26 @@ abstract class BaseController {
} }
} }
Future<List<int>> 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<bool> runDeleteWithAuth(String uriStr) async { Future<bool> runDeleteWithAuth(String uriStr) async {
http.Client client = httpClientUtils.client; http.Client client = httpClientUtils.client;
Header cred = await getAuthHeader(); Header cred = getAuthHeader();
Uri uri = Uri.parse(uriStr); Uri uri = Uri.parse(uriStr);
var response = await client.delete(uri, headers: {cred.name: cred.value}); var response = await client.delete(uri, headers: {cred.name: cred.value});
return response.statusCode == 200; return response.statusCode == 200;
@@ -77,13 +86,28 @@ abstract class BaseController {
Future<bool> runPutWithAuth(String uriStr) async { Future<bool> runPutWithAuth(String uriStr) async {
http.Client client = httpClientUtils.client; http.Client client = httpClientUtils.client;
Header cred = await getAuthHeader(); Header cred = getAuthHeader();
Uri uri = Uri.parse(uriStr); Uri uri = Uri.parse(uriStr);
var response = await client.put(uri, headers: {cred.name: cred.value}); var response = await client.put(uri, headers: {cred.name: cred.value});
return response.statusCode == 200; 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 { class Header {
final String name; final String name;
final String value; final String value;

View File

@@ -5,6 +5,8 @@ abstract interface class CustomerController {
Future<List<CustomerListDto>> getAll(String query, String startsWith); Future<List<CustomerListDto>> getAll(String query, String startsWith);
Future<CustomerDto?> get({required int id}); Future<CustomerDto?> get({required int id});
Future<List<int>> export({required int customerId, int? pictureId});
} }
class CustomerControllerImpl extends BaseController implements CustomerController { class CustomerControllerImpl extends BaseController implements CustomerController {
@@ -28,4 +30,13 @@ class CustomerControllerImpl extends BaseController implements CustomerControlle
String uriStr = '${uriUtils.getBaseUrl()}$path/$id'; String uriStr = '${uriUtils.getBaseUrl()}$path/$id';
return runGetWithAuth(uriStr, (json) => CustomerDto.fromJson(json)); return runGetWithAuth(uriStr, (json) => CustomerDto.fromJson(json));
} }
@override
Future<List<int>> export({required int customerId, int? pictureId}) {
String uriStr = '${uriUtils.getBaseUrl()}$path/export/$customerId';
if (pictureId != null) {
uriStr += '?picture=$pictureId';
}
return runGetBytesWithAuth(uriStr);
}
} }

View File

@@ -13,7 +13,7 @@ typedef AuthenticateReply = ({JwtTokenPairDto? jwtTokenPairDto});
abstract interface class LoginController { abstract interface class LoginController {
Future<AuthenticateReply> authenticate(String username, String password); Future<AuthenticateReply> authenticate(String username, String password);
Future<bool> refreshAccessToken(); Future<bool> refreshAccessToken();
Future<bool> isUsingJwtAuth(); bool isUsingJwtAuth();
} }
class LoginControllerImpl extends BaseController implements LoginController { class LoginControllerImpl extends BaseController implements LoginController {
@@ -36,7 +36,7 @@ class LoginControllerImpl extends BaseController implements LoginController {
final tokenPair = JwtTokenPairDto.fromJson(data); final tokenPair = JwtTokenPairDto.fromJson(data);
// Store tokens securely // Store tokens securely
await _jwtTokenStorage.saveTokens(tokenPair.accessToken, tokenPair.refreshToken); _jwtTokenStorage.saveTokens(tokenPair.accessToken, tokenPair.refreshToken);
// Load user data using the new token // Load user data using the new token
return (jwtTokenPairDto: tokenPair); return (jwtTokenPairDto: tokenPair);
@@ -53,7 +53,7 @@ class LoginControllerImpl extends BaseController implements LoginController {
@override @override
Future<bool> refreshAccessToken() async { Future<bool> refreshAccessToken() async {
try { try {
final refreshToken = await _jwtTokenStorage.getRefreshToken(); final refreshToken = _jwtTokenStorage.getRefreshToken();
if (refreshToken == null) { if (refreshToken == null) {
logger.i('No refresh token available'); logger.i('No refresh token available');
return false; return false;
@@ -74,7 +74,7 @@ class LoginControllerImpl extends BaseController implements LoginController {
final newAccessToken = data['accessToken'] as String; final newAccessToken = data['accessToken'] as String;
// Update only the access token (keep same refresh token) // Update only the access token (keep same refresh token)
await _jwtTokenStorage.updateAccessToken(newAccessToken); _jwtTokenStorage.updateAccessToken(newAccessToken);
logger.d('Access token refreshed successfully'); logger.d('Access token refreshed successfully');
return true; return true;
@@ -89,8 +89,8 @@ class LoginControllerImpl extends BaseController implements LoginController {
} }
@override @override
Future<bool> isUsingJwtAuth() async { bool isUsingJwtAuth() {
return await _jwtTokenStorage.hasTokens(); return _jwtTokenStorage.hasTokens();
} }
Header _getLoginHeader(String username, String password) { Header _getLoginHeader(String username, String password) {

View File

@@ -22,7 +22,7 @@ void main() async {
await initializeDateFormatting('de_DE', null); await initializeDateFormatting('de_DE', null);
LoginController loginController = DiContainer.get(); LoginController loginController = DiContainer.get();
await loginController.isUsingJwtAuth(); loginController.isUsingJwtAuth();
runApp(FotoDocumentationApp(theme: theme)); runApp(FotoDocumentationApp(theme: theme));
} }

View File

@@ -14,6 +14,7 @@ import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart'; import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart'; import 'package:fotodocumentation/utils/global_router.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:fotodocumentation/utils/file_download.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class CustomerWidget extends StatefulWidget { class CustomerWidget extends StatefulWidget {
@@ -202,6 +203,7 @@ class _CustomerWidgetState extends State<CustomerWidget> {
final dateStr = _dateFormat.format(pictureDto.pictureDate); final dateStr = _dateFormat.format(pictureDto.pictureDate);
final evaluationColor = _generalStyle.evaluationColor(value: pictureDto.evaluation); final evaluationColor = _generalStyle.evaluationColor(value: pictureDto.evaluation);
Header cred = HeaderUtils().getAuthHeader();
return InkWell( return InkWell(
key: Key("table_row_${customerDto.id}"), key: Key("table_row_${customerDto.id}"),
onTap: () => _actionSelect(context, customerDto, pictureDto), onTap: () => _actionSelect(context, customerDto, pictureDto),
@@ -219,6 +221,7 @@ class _CustomerWidgetState extends State<CustomerWidget> {
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 70, maxHeight: 70), constraints: const BoxConstraints(maxWidth: 70, maxHeight: 70),
child: Image.network( child: Image.network(
headers: {cred.name: cred.value},
pictureDto.thumbnailSizeUrl, pictureDto.thumbnailSizeUrl,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
@@ -334,6 +337,10 @@ class _CustomerWidgetState extends State<CustomerWidget> {
} }
Future<void> _actionDownload(BuildContext context, CustomerDto customerDto, PictureDto? pictureDto) async { Future<void> _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);
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/dto/picture_dto.dart'; import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart'; import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart'; import 'package:fotodocumentation/utils/di_container.dart';
@@ -15,6 +16,7 @@ class PictureFullscreenDialog extends StatelessWidget {
final screenSize = MediaQuery.of(context).size; final screenSize = MediaQuery.of(context).size;
final dialogWidth = screenSize.width * 0.9; final dialogWidth = screenSize.width * 0.9;
final dialogHeight = screenSize.height * 0.9 - 50; final dialogHeight = screenSize.height * 0.9 - 50;
Header cred = HeaderUtils().getAuthHeader();
return Dialog( return Dialog(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@@ -50,6 +52,7 @@ class PictureFullscreenDialog extends StatelessWidget {
minScale: 0.5, minScale: 0.5,
maxScale: 5.0, maxScale: 5.0,
child: Image.network( child: Image.network(
headers: {cred.name: cred.value},
dto.imageUrl, dto.imageUrl,
), ),
), ),

View File

@@ -144,6 +144,8 @@ class _PictureWidgetState extends State<PictureWidget> {
} }
Widget _imageWidget(PictureDto dto) { Widget _imageWidget(PictureDto dto) {
Header cred = HeaderUtils().getAuthHeader();
return GestureDetector( return GestureDetector(
key: const Key("image"), key: const Key("image"),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
@@ -153,6 +155,7 @@ class _PictureWidgetState extends State<PictureWidget> {
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 100, minHeight: 100), constraints: const BoxConstraints(minWidth: 100, minHeight: 100),
child: Image.network( child: Image.network(
headers: {cred.name: cred.value},
dto.normalSizeUrl, dto.normalSizeUrl,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
@@ -176,16 +179,17 @@ class _PictureWidgetState extends State<PictureWidget> {
color: _generalStyle.secondaryTextLabelColor, color: _generalStyle.secondaryTextLabelColor,
); );
String dateText = _dateFormat.format(dto.pictureDate);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
_dateFormat.format(dto.pictureDate), "$dateText UHR",
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 44, fontSize: 32,
fontFamily: _generalStyle.fontFamily, fontFamily: _generalStyle.fontFamily,
color: _generalStyle.primaryTextLabelColor, color: _generalStyle.secondaryWidgetBackgroundColor,
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),

View File

@@ -0,0 +1 @@
export 'file_download_stub.dart' if (dart.library.js) 'file_download_web.dart' if (dart.library.io) 'file_download_app.dart';

View File

@@ -0,0 +1,3 @@
Future<void> downloadFile(List<int> bytes, String fileName) {
throw UnsupportedError('File download not supported on this platform');
}

View File

@@ -0,0 +1,3 @@
Future<void> downloadFile(List<int> bytes, String fileName) {
throw UnsupportedError('Cannot download file on this platform');
}

View File

@@ -0,0 +1,13 @@
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
Future<void> downloadFile(List<int> bytes, String fileName) async {
await FilePicker.platform.saveFile(
dialogTitle: fileName,
fileName: fileName,
type: FileType.custom,
allowedExtensions: ['pdf'],
bytes: Uint8List.fromList(bytes),
);
}

View File

@@ -3,30 +3,30 @@ abstract class JwtTokenStorage {
/// ///
/// @param accessToken The short-lived access token /// @param accessToken The short-lived access token
/// @param refreshToken The long-lived refresh token /// @param refreshToken The long-lived refresh token
Future<void> saveTokens(String accessToken, String refreshToken); void saveTokens(String accessToken, String refreshToken);
/// Get the stored access token /// Get the stored access token
/// ///
/// @return Access token or null if not found /// @return Access token or null if not found
Future<String?> getAccessToken(); String? getAccessToken();
/// Get the stored refresh token /// Get the stored refresh token
/// ///
/// @return Refresh token or null if not found /// @return Refresh token or null if not found
Future<String?> getRefreshToken(); String? getRefreshToken();
/// Clear all stored tokens (on logout) /// Clear all stored tokens (on logout)
Future<void> clearTokens(); void clearTokens();
/// Check if tokens are stored /// Check if tokens are stored
/// ///
/// @return true if access token exists /// @return true if access token exists
Future<bool> hasTokens(); bool hasTokens();
/// Update only the access token (used after refresh) /// Update only the access token (used after refresh)
/// ///
/// @param accessToken New access token /// @param accessToken New access token
Future<void> updateAccessToken(String accessToken); void updateAccessToken(String accessToken);
} }
class JwtTokenStorageImpl extends JwtTokenStorage { class JwtTokenStorageImpl extends JwtTokenStorage {
@@ -36,34 +36,34 @@ class JwtTokenStorageImpl extends JwtTokenStorage {
String? _keyRefreshToken; String? _keyRefreshToken;
@override @override
Future<void> saveTokens(String accessToken, String refreshToken) async { void saveTokens(String accessToken, String refreshToken) async {
_keyAccessToken = accessToken; _keyAccessToken = accessToken;
_keyRefreshToken = refreshToken; _keyRefreshToken = refreshToken;
} }
@override @override
Future<String?> getAccessToken() async { String? getAccessToken() {
return _keyAccessToken; return _keyAccessToken;
} }
@override @override
Future<String?> getRefreshToken() async { String? getRefreshToken() {
return _keyRefreshToken; return _keyRefreshToken;
} }
@override @override
Future<void> clearTokens() async { void clearTokens() {
_keyAccessToken = null; _keyAccessToken = null;
_keyRefreshToken = null; _keyRefreshToken = null;
} }
@override @override
Future<bool> hasTokens() async { bool hasTokens() {
return _keyAccessToken != null && _keyAccessToken!.isNotEmpty; return _keyAccessToken != null && _keyAccessToken!.isNotEmpty;
} }
@override @override
Future<void> updateAccessToken(String accessToken) async { void updateAccessToken(String accessToken) {
_keyAccessToken == accessToken; _keyAccessToken == accessToken;
} }
} }

View File

@@ -14,7 +14,7 @@ import '../testing/test_utils.mocks.dart';
void main() { void main() {
DiContainer.instance.initState(); DiContainer.instance.initState();
var jwtTokenStorage = MockJwtTokenStorage(); var jwtTokenStorage = MockJwtTokenStorage();
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) async => null); when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null);
DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage); DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage);
CustomerController controller = CustomerControllerImpl(); CustomerController controller = CustomerControllerImpl();

View File

@@ -13,7 +13,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
DiContainer.instance.initState(); DiContainer.instance.initState();
var jwtTokenStorage = MockJwtTokenStorage(); var jwtTokenStorage = MockJwtTokenStorage();
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) async => null); when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null);
DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage); DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage);
LoginController controller = LoginControllerImpl(); LoginController controller = LoginControllerImpl();

View File

@@ -13,7 +13,7 @@ import '../testing/test_utils.mocks.dart';
void main() { void main() {
DiContainer.instance.initState(); DiContainer.instance.initState();
var jwtTokenStorage = MockJwtTokenStorage(); var jwtTokenStorage = MockJwtTokenStorage();
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) async => null); when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null);
DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage); DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage);
PictureController controller = PictureControllerImpl(); PictureController controller = PictureControllerImpl();

View File

@@ -175,13 +175,13 @@ class MockLoginController extends _i1.Mock implements _i6.LoginController {
) as _i7.Future<bool>); ) as _i7.Future<bool>);
@override @override
_i7.Future<bool> isUsingJwtAuth() => (super.noSuchMethod( bool isUsingJwtAuth() => (super.noSuchMethod(
Invocation.method( Invocation.method(
#isUsingJwtAuth, #isUsingJwtAuth,
[], [],
), ),
returnValue: _i7.Future<bool>.value(false), returnValue: false,
) as _i7.Future<bool>); ) as bool);
} }
/// A class which mocks [CustomerController]. /// A class which mocks [CustomerController].
@@ -219,6 +219,23 @@ class MockCustomerController extends _i1.Mock
), ),
returnValue: _i7.Future<_i10.CustomerDto?>.value(), returnValue: _i7.Future<_i10.CustomerDto?>.value(),
) as _i7.Future<_i10.CustomerDto?>); ) as _i7.Future<_i10.CustomerDto?>);
@override
_i7.Future<List<int>> export({
required int? customerId,
int? pictureId,
}) =>
(super.noSuchMethod(
Invocation.method(
#export,
[],
{
#customerId: customerId,
#pictureId: pictureId,
},
),
returnValue: _i7.Future<List<int>>.value(<int>[]),
) as _i7.Future<List<int>>);
} }
/// A class which mocks [PictureController]. /// A class which mocks [PictureController].
@@ -258,11 +275,11 @@ class MockJwtTokenStorage extends _i1.Mock implements _i13.JwtTokenStorage {
} }
@override @override
_i7.Future<void> saveTokens( void saveTokens(
String? accessToken, String? accessToken,
String? refreshToken, String? refreshToken,
) => ) =>
(super.noSuchMethod( super.noSuchMethod(
Invocation.method( Invocation.method(
#saveTokens, #saveTokens,
[ [
@@ -270,57 +287,35 @@ class MockJwtTokenStorage extends _i1.Mock implements _i13.JwtTokenStorage {
refreshToken, refreshToken,
], ],
), ),
returnValue: _i7.Future<void>.value(), returnValueForMissingStub: null,
returnValueForMissingStub: _i7.Future<void>.value(), );
) as _i7.Future<void>);
@override @override
_i7.Future<String?> getAccessToken() => (super.noSuchMethod( void clearTokens() => super.noSuchMethod(
Invocation.method(
#getAccessToken,
[],
),
returnValue: _i7.Future<String?>.value(),
) as _i7.Future<String?>);
@override
_i7.Future<String?> getRefreshToken() => (super.noSuchMethod(
Invocation.method(
#getRefreshToken,
[],
),
returnValue: _i7.Future<String?>.value(),
) as _i7.Future<String?>);
@override
_i7.Future<void> clearTokens() => (super.noSuchMethod(
Invocation.method( Invocation.method(
#clearTokens, #clearTokens,
[], [],
), ),
returnValue: _i7.Future<void>.value(), returnValueForMissingStub: null,
returnValueForMissingStub: _i7.Future<void>.value(), );
) as _i7.Future<void>);
@override @override
_i7.Future<bool> hasTokens() => (super.noSuchMethod( bool hasTokens() => (super.noSuchMethod(
Invocation.method( Invocation.method(
#hasTokens, #hasTokens,
[], [],
), ),
returnValue: _i7.Future<bool>.value(false), returnValue: false,
) as _i7.Future<bool>); ) as bool);
@override @override
_i7.Future<void> updateAccessToken(String? accessToken) => void updateAccessToken(String? accessToken) => super.noSuchMethod(
(super.noSuchMethod(
Invocation.method( Invocation.method(
#updateAccessToken, #updateAccessToken,
[accessToken], [accessToken],
), ),
returnValue: _i7.Future<void>.value(), returnValueForMissingStub: null,
returnValueForMissingStub: _i7.Future<void>.value(), );
) as _i7.Future<void>);
} }
/// A class which mocks [Client]. /// A class which mocks [Client].

View File

@@ -11,29 +11,29 @@ void main() {
test('initially has no tokens', () async { test('initially has no tokens', () async {
// Verify initial state is empty // Verify initial state is empty
expect(await storage.getAccessToken(), isNull); expect(storage.getAccessToken(), isNull);
expect(await storage.getRefreshToken(), isNull); expect(storage.getRefreshToken(), isNull);
expect(await storage.hasTokens(), isFalse); expect(storage.hasTokens(), isFalse);
}); });
test('saveTokens stores both access and refresh tokens', () async { test('saveTokens stores both access and refresh tokens', () async {
const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access'; const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access';
const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh';
await storage.saveTokens(accessToken, refreshToken); storage.saveTokens(accessToken, refreshToken);
expect(await storage.getAccessToken(), equals(accessToken)); expect(storage.getAccessToken(), equals(accessToken));
expect(await storage.getRefreshToken(), equals(refreshToken)); expect(storage.getRefreshToken(), equals(refreshToken));
expect(await storage.hasTokens(), isTrue); expect(storage.hasTokens(), isTrue);
}); });
test('getAccessToken returns correct token after save', () async { test('getAccessToken returns correct token after save', () async {
const accessToken = 'test_access_token_123'; const accessToken = 'test_access_token_123';
const refreshToken = 'test_refresh_token_456'; 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)); expect(retrievedAccessToken, equals(accessToken));
}); });
@@ -41,9 +41,9 @@ void main() {
const accessToken = 'test_access_token_123'; const accessToken = 'test_access_token_123';
const refreshToken = 'test_refresh_token_456'; 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)); expect(retrievedRefreshToken, equals(refreshToken));
}); });
@@ -52,35 +52,35 @@ void main() {
const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh';
// First save tokens // First save tokens
await storage.saveTokens(accessToken, refreshToken); storage.saveTokens(accessToken, refreshToken);
expect(await storage.hasTokens(), isTrue); expect(storage.hasTokens(), isTrue);
// Then clear them // Then clear them
await storage.clearTokens(); storage.clearTokens();
expect(await storage.getAccessToken(), isNull); expect(storage.getAccessToken(), isNull);
expect(await storage.getRefreshToken(), isNull); expect(storage.getRefreshToken(), isNull);
expect(await storage.hasTokens(), isFalse); expect(storage.hasTokens(), isFalse);
}); });
test('hasTokens returns true when access token exists', () async { test('hasTokens returns true when access token exists', () async {
const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access'; const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access';
const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; 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 { test('hasTokens returns false when access token is empty string', () async {
const accessToken = ''; const accessToken = '';
const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; 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 { test('updateAccessToken updates only the access token', () async {
@@ -89,20 +89,20 @@ void main() {
const newAccessToken = 'new_access_token'; const newAccessToken = 'new_access_token';
// Save initial tokens // Save initial tokens
await storage.saveTokens(initialAccessToken, initialRefreshToken); storage.saveTokens(initialAccessToken, initialRefreshToken);
// Update access token // Update access token
await storage.updateAccessToken(newAccessToken); storage.updateAccessToken(newAccessToken);
// Note: Due to bug in implementation (line 67 uses == instead of =), // Note: Due to bug in implementation (line 67 uses == instead of =),
// this test will fail. The access token won't actually be updated. // this test will fail. The access token won't actually be updated.
// Uncomment below when bug is fixed: // Uncomment below when bug is fixed:
// expect(await storage.getAccessToken(), equals(newAccessToken)); // expect(storage.getAccessToken(), equals(newAccessToken));
// expect(await storage.getRefreshToken(), equals(initialRefreshToken)); // expect(storage.getRefreshToken(), equals(initialRefreshToken));
// Current behavior (with bug): // Current behavior (with bug):
expect(await storage.getAccessToken(), equals(initialAccessToken)); expect(storage.getAccessToken(), equals(initialAccessToken));
expect(await storage.getRefreshToken(), equals(initialRefreshToken)); expect(storage.getRefreshToken(), equals(initialRefreshToken));
}); });
test('saveTokens can overwrite existing tokens', () async { test('saveTokens can overwrite existing tokens', () async {
@@ -112,27 +112,27 @@ void main() {
const secondRefreshToken = 'second_refresh_token'; const secondRefreshToken = 'second_refresh_token';
// Save first set of tokens // Save first set of tokens
await storage.saveTokens(firstAccessToken, firstRefreshToken); storage.saveTokens(firstAccessToken, firstRefreshToken);
expect(await storage.getAccessToken(), equals(firstAccessToken)); expect(storage.getAccessToken(), equals(firstAccessToken));
expect(await storage.getRefreshToken(), equals(firstRefreshToken)); expect(storage.getRefreshToken(), equals(firstRefreshToken));
// Overwrite with second set // Overwrite with second set
await storage.saveTokens(secondAccessToken, secondRefreshToken); storage.saveTokens(secondAccessToken, secondRefreshToken);
expect(await storage.getAccessToken(), equals(secondAccessToken)); expect(storage.getAccessToken(), equals(secondAccessToken));
expect(await storage.getRefreshToken(), equals(secondRefreshToken)); expect(storage.getRefreshToken(), equals(secondRefreshToken));
}); });
test('clearTokens can be called multiple times safely', () async { test('clearTokens can be called multiple times safely', () async {
const accessToken = 'test_access_token'; const accessToken = 'test_access_token';
const refreshToken = 'test_refresh_token'; const refreshToken = 'test_refresh_token';
await storage.saveTokens(accessToken, refreshToken); storage.saveTokens(accessToken, refreshToken);
await storage.clearTokens(); storage.clearTokens();
await storage.clearTokens(); // Call again storage.clearTokens(); // Call again
expect(await storage.getAccessToken(), isNull); expect(storage.getAccessToken(), isNull);
expect(await storage.getRefreshToken(), isNull); expect(storage.getRefreshToken(), isNull);
expect(await storage.hasTokens(), isFalse); expect(storage.hasTokens(), isFalse);
}); });
test('handles long JWT tokens correctly', () async { test('handles long JWT tokens correctly', () async {
@@ -143,40 +143,40 @@ void main() {
'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.' 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.'
'Ks_BdfH4CWilyzLNk8S2gShdsGuhkle-VsNBJJxulJc'; 'Ks_BdfH4CWilyzLNk8S2gShdsGuhkle-VsNBJJxulJc';
await storage.saveTokens(longAccessToken, longRefreshToken); storage.saveTokens(longAccessToken, longRefreshToken);
expect(await storage.getAccessToken(), equals(longAccessToken)); expect(storage.getAccessToken(), equals(longAccessToken));
expect(await storage.getRefreshToken(), equals(longRefreshToken)); expect(storage.getRefreshToken(), equals(longRefreshToken));
expect(await storage.hasTokens(), isTrue); expect(storage.hasTokens(), isTrue);
}); });
test('typical authentication flow', () async { test('typical authentication flow', () async {
// 1. Initial state - no tokens // 1. Initial state - no tokens
expect(await storage.hasTokens(), isFalse); expect(storage.hasTokens(), isFalse);
// 2. User logs in - tokens are saved // 2. User logs in - tokens are saved
const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access'; const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access';
const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh';
await storage.saveTokens(accessToken, refreshToken); storage.saveTokens(accessToken, refreshToken);
expect(await storage.hasTokens(), isTrue); expect(storage.hasTokens(), isTrue);
expect(await storage.getAccessToken(), equals(accessToken)); expect(storage.getAccessToken(), equals(accessToken));
expect(await storage.getRefreshToken(), equals(refreshToken)); expect(storage.getRefreshToken(), equals(refreshToken));
// 3. Access token expires, refresh with new access token // 3. Access token expires, refresh with new access token
const newAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access'; const newAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access';
await storage.updateAccessToken(newAccessToken); storage.updateAccessToken(newAccessToken);
// Note: Due to bug, this won't work as expected // Note: Due to bug, this won't work as expected
// expect(await storage.getAccessToken(), equals(newAccessToken)); // expect(storage.getAccessToken(), equals(newAccessToken));
// expect(await storage.getRefreshToken(), equals(refreshToken)); // expect(storage.getRefreshToken(), equals(refreshToken));
// 4. User logs out - tokens are cleared // 4. User logs out - tokens are cleared
await storage.clearTokens(); storage.clearTokens();
expect(await storage.hasTokens(), isFalse); expect(storage.hasTokens(), isFalse);
expect(await storage.getAccessToken(), isNull); expect(storage.getAccessToken(), isNull);
expect(await storage.getRefreshToken(), isNull); expect(storage.getRefreshToken(), isNull);
}); });
}); });
} }

View File

@@ -5,7 +5,9 @@
<web-resource-collection> <web-resource-collection>
<web-resource-name>Secure</web-resource-name> <web-resource-name>Secure</web-resource-name>
<url-pattern>/api/customer</url-pattern> <url-pattern>/api/customer</url-pattern>
<url-pattern>/api/customer/*</url-pattern>
<url-pattern>/api/picture</url-pattern> <url-pattern>/api/picture</url-pattern>
<url-pattern>/api/picture/*</url-pattern>
<http-method>GET</http-method> <http-method>GET</http-method>
<http-method>POST</http-method> <http-method>POST</http-method>