打印服务

This commit is contained in:
2026-08-04 18:54:22 +08:00
parent b7d36d4766
commit dc2f844467
44 changed files with 2576 additions and 110 deletions
@@ -0,0 +1,18 @@
package com.baiyue.print;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(
classes = PrintServiceApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {
"spring.cloud.nacos.config.enabled=false",
"spring.cloud.nacos.discovery.enabled=false",
"baiyue.security.internal-service-token=test-internal-token-at-least-32-bytes"
})
class PrintServiceApplicationTest {
@Test
void startsWithoutDatabaseRedisOrNacos() {
}
}
@@ -0,0 +1,179 @@
package com.baiyue.print.acceptance;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import com.baiyue.print.service.InboundMarkPdfService;
import com.baiyue.print.service.PdfGenerationLimiter;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.RGBLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** End-to-end renderer acceptance checks. These intentionally exercise the real service, not mocks. */
class InboundMarkPdfAcceptanceTest {
private static final String TEMPLATE = "INBOUND_MARK_V1";
private static final BigDecimal MM_100 = new BigDecimal("100");
@Test
void generatesOnePageWithExactSizeTextOrderEmbeddedChineseFontAndNoDiskArtifact(@TempDir Path tempDir)
throws Exception {
InboundMarkPdfService service = service();
InboundMarkPdfDtos.Label label = label(1);
String originalTmp = System.getProperty("java.io.tmpdir");
System.setProperty("java.io.tmpdir", tempDir.toString());
try {
byte[] pdf = service.generate(request(List.of(label))).content();
assertPdf(pdf, List.of(label));
try (Stream<Path> files = Files.list(tempDir)) {
assertThat(files.filter(Files::isRegularFile).toList())
.as("PDF rendering must not create temporary files")
.isEmpty();
}
} finally {
if (originalTmp == null) {
System.clearProperty("java.io.tmpdir");
} else {
System.setProperty("java.io.tmpdir", originalTmp);
}
}
}
@Test
void preservesPageOrderAndDecodableQrOnFirstMiddleAndLastPages() throws Exception {
List<InboundMarkPdfDtos.Label> labels = labels(19);
assertPdf(service().generate(request(labels)).content(), labels);
}
@Test
void generatesTwoHundredEighteenPagesWithExactDimensions() throws Exception {
List<InboundMarkPdfDtos.Label> labels = labels(218);
byte[] pdf = service().generate(request(labels)).content();
try (PDDocument document = Loader.loadPDF(pdf)) {
assertThat(document.getNumberOfPages()).isEqualTo(218);
for (int i : List.of(0, 108, 217)) {
assertExactPageSize(document, i);
}
}
}
@Test
void maximumCapacityIsCoveredWhenExplicitlyEnabled() {
assumeTrue(Boolean.getBoolean("print.capacity.tests"),
"Enable with -Dprint.capacity.tests=true for the 2000-page capacity acceptance test");
List<InboundMarkPdfDtos.Label> labels = labels(2000);
byte[] pdf = service().generate(request(labels)).content();
assertThat(pdf).startsWith("%PDF".getBytes(StandardCharsets.US_ASCII));
assertThat(pdf.length).isLessThan(32 * 1024 * 1024);
try (PDDocument document = Loader.loadPDF(pdf)) {
assertThat(document.getNumberOfPages()).isEqualTo(2000);
} catch (Exception exception) {
throw new AssertionError("Generated 2000-page PDF must be readable", exception);
}
}
private void assertPdf(byte[] bytes, List<InboundMarkPdfDtos.Label> labels) throws Exception {
try (PDDocument document = Loader.loadPDF(bytes)) {
assertThat(document.getNumberOfPages()).isEqualTo(labels.size());
PDFTextStripper stripper = new PDFTextStripper();
for (int i : representativeIndexes(labels.size())) {
assertExactPageSize(document, i);
stripper.setStartPage(i + 1);
stripper.setEndPage(i + 1);
String text = stripper.getText(document);
InboundMarkPdfDtos.Label label = labels.get(i);
assertThat(text).contains(label.topCode(), label.warehouseText(), label.cartonText(),
label.routeText(), label.qrContent(), "MADE IN CHINA");
assertThat(text.indexOf(label.topCode())).isLessThan(text.indexOf(label.warehouseText()));
assertThat(text.indexOf(label.warehouseText())).isLessThan(text.indexOf(label.cartonText()));
assertThat(text.indexOf(label.cartonText())).isLessThan(text.indexOf(label.routeText()));
assertThat(text.indexOf(label.routeText())).isLessThan(text.indexOf(label.qrContent()));
assertThat(text.lines().filter(label.topCode()::equals).count())
.as("top code must appear as exactly one standalone line")
.isEqualTo(1);
assertEmbeddedFont(document, i);
}
decodeQrs(document, labels);
}
}
private void decodeQrs(PDDocument document, List<InboundMarkPdfDtos.Label> labels) throws Exception {
PDFRenderer renderer = new PDFRenderer(document);
for (int i : representativeIndexes(labels.size())) {
BufferedImage image = renderer.renderImageWithDPI(i, 300);
int[] pixels = image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(
new RGBLuminanceSource(image.getWidth(), image.getHeight(), pixels)));
String decoded = new MultiFormatReader().decode(bitmap).getText();
assertThat(decoded).isEqualTo(labels.get(i).qrContent());
}
}
private void assertEmbeddedFont(PDDocument document, int pageIndex) throws Exception {
boolean embedded = false;
for (org.apache.pdfbox.cos.COSName name : document.getPage(pageIndex).getResources().getFontNames()) {
PDFont font = document.getPage(pageIndex).getResources().getFont(name);
embedded |= font.isEmbedded();
}
assertThat(embedded).as("page %s must contain an embedded font", pageIndex + 1).isTrue();
}
private void assertExactPageSize(PDDocument document, int index) {
float expected = 100f * 72f / 25.4f;
assertThat(document.getPage(index).getMediaBox().getWidth()).isCloseTo(expected,
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(index).getMediaBox().getHeight()).isCloseTo(expected,
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(index).getRotation()).isZero();
}
private List<Integer> representativeIndexes(int size) {
if (size == 1) return List.of(0);
return List.of(0, size / 2, size - 1);
}
private InboundMarkPdfService service() {
PrintPdfProperties properties = new PrintPdfProperties();
return new InboundMarkPdfService(properties, new PdfGenerationLimiter(properties),
new InboundMarkV1Renderer(new PdfQrRenderer()), new PdfQrRenderer());
}
private InboundMarkPdfDtos.Request request(List<InboundMarkPdfDtos.Label> labels) {
return new InboundMarkPdfDtos.Request(TEMPLATE,
new InboundMarkPdfDtos.PageSpec(MM_100, MM_100), "acceptance.pdf", labels);
}
private List<InboundMarkPdfDtos.Label> labels(int total) {
List<InboundMarkPdfDtos.Label> labels = new ArrayList<>(total);
for (int i = 1; i <= total; i++) labels.add(label(i, total));
return labels;
}
private InboundMarkPdfDtos.Label label(int box) { return label(box, 1); }
private InboundMarkPdfDtos.Label label(int box, int total) {
String suffix = total + "-" + box;
return new InboundMarkPdfDtos.Label("PCHK2608040174", "MCC1", "CTNS:" + suffix,
"洛杉矶普船海卡", "PCHK2608040174#" + suffix);
}
}
@@ -0,0 +1,110 @@
package com.baiyue.print.acceptance;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/** Structural guardrails for the stateless service boundary. */
class PrintStatelessArchitectureTest {
@Test
void moduleDoesNotDeclareDatabaseOrMybatisDependencies() throws Exception {
String pom = Files.readString(moduleRoot().resolve("pom.xml"), StandardCharsets.UTF_8);
assertThat(pom).doesNotContain("mybatis-plus-spring-boot3-starter")
.doesNotContain("mysql-connector-j")
.doesNotContain("spring-jdbc")
.doesNotContain("spring-boot-starter-jdbc");
}
@Test
void sourceAndConfigurationDoNotDeclareDatasourceOrDatabaseAccess() throws Exception {
Path root = moduleRoot();
String source;
try (Stream<Path> paths = Files.walk(root.resolve("src/main"))) {
source = paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".java") || path.toString().endsWith(".yml")
|| path.toString().endsWith(".yaml"))
.map(this::read)
.collect(Collectors.joining("\n"));
}
assertThat(source).doesNotContain("DataSource").doesNotContain("JdbcTemplate")
.doesNotContain("@Mapper").doesNotContain("spring.datasource")
.doesNotContain("jdbc:");
}
@Test
void obsoleteDatabaseScriptIsNotPackaged() {
assertThat(Files.exists(moduleRoot().resolve("src/main/resources/db/print.sql"))).isFalse();
}
@Test
void runtimeClasspathDoesNotBringBackJdbcOrMybatisTransitively() {
ClassLoader loader = getClass().getClassLoader();
assertThat(isPresent("org.springframework.jdbc.core.JdbcTemplate", loader))
.as("Print must exclude the JDBC starter inherited through common-core")
.isFalse();
assertThat(isPresent("com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration", loader))
.as("Print must exclude MyBatis inherited through common-core")
.isFalse();
assertThat(isPresent("org.springframework.data.redis.core.StringRedisTemplate", loader))
.as("Print must not require Redis for authentication or health")
.isFalse();
}
@Test
void configuredRequestByteLimitHasAnEnforcementConsumer() throws Exception {
String consumers = productionJavaExcept("PrintPdfProperties.java");
assertThat(consumers).as("max-request-bytes must be enforced before JSON deserialization")
.contains("getMaxRequestBytes()");
}
@Test
void fontResourceIsValidatedAtStartupOrReadiness() throws Exception {
String consumers = productionJavaExcept("PrintPdfProperties.java");
assertThat(consumers).contains("getFontResource()");
assertThat(consumers)
.as("a missing or invalid font must make startup/readiness fail, not wait for a PDF request")
.containsAnyOf("HealthIndicator", "ApplicationRunner", "SmartInitializingSingleton", "@PostConstruct");
}
private Path moduleRoot() {
Path cwd = Path.of("").toAbsolutePath().normalize();
for (Path candidate : new Path[]{cwd, cwd.resolve("print"), cwd.resolve("baiyue-firstleg/print")}) {
if (Files.exists(candidate.resolve("pom.xml")) && Files.exists(candidate.resolve("src/main"))) {
return candidate;
}
}
throw new IllegalStateException("Cannot locate the print module from " + cwd);
}
private String read(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
private String productionJavaExcept(String excludedFileName) throws Exception {
try (Stream<Path> paths = Files.walk(moduleRoot().resolve("src/main/java"))) {
return paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".java"))
.filter(path -> !path.getFileName().toString().equals(excludedFileName))
.map(this::read)
.collect(Collectors.joining("\n"));
}
}
private boolean isPresent(String className, ClassLoader loader) {
try {
Class.forName(className, false, loader);
return true;
} catch (ClassNotFoundException ignored) {
return false;
}
}
}
@@ -0,0 +1,23 @@
package com.baiyue.print.config;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Status;
class PrintFontHealthIndicatorTest {
@Test
void reportsUpForPackagedParseableFont() {
PrintPdfProperties properties = new PrintPdfProperties();
assertThat(new PrintFontHealthIndicator(properties).health().getStatus()).isEqualTo(Status.UP);
}
@Test
void reportsDownForMissingFont() {
PrintPdfProperties properties = new PrintPdfProperties();
properties.setFontResource("fonts/missing.ttf");
assertThat(new PrintFontHealthIndicator(properties).health().getStatus()).isEqualTo(Status.DOWN);
}
}
@@ -0,0 +1,162 @@
package com.baiyue.print.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.InboundMarkPdfService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.PostMapping;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.mockito.ArgumentCaptor;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class InternalPdfControllerTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
private InboundMarkPdfService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(InboundMarkPdfService.class);
InternalPdfController controller = new InternalPdfController(
service, new InternalServiceTokenGuard(TOKEN));
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void returnsPdfWithDownloadHeaders() throws Exception {
byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII);
when(service.generate(any())).thenReturn(new InboundMarkPdfService.GeneratedPdf(
pdf, "PCHK2608040174-19件-MCC1.pdf", 19));
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.requestAttr("requestId", "request-123")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(content().bytes(pdf))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("Pragma", "no-cache"))
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().string("X-Print-Page-Count", "19"))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("attachment")))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("filename*=")));
verify(service).generate(any(InboundMarkPdfDtos.Request.class));
}
@Test
void rejectsMissingTokenAsJsonBeforeCallingService() throws Exception {
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value("A0403"));
verify(service, never()).generate(any());
}
@Test
void returnsServiceValidationFailureAsJson() throws Exception {
when(service.generate(any())).thenThrow(
new BizException(ResultCode.VALIDATION_FAILED, "labels must contain at least one item"));
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value("A0422"))
.andExpect(jsonPath("$.message").value("labels must contain at least one item"));
}
@Test
void derivesDefaultFileNameFromRequestId() throws Exception {
byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII);
when(service.generate(any())).thenReturn(new InboundMarkPdfService.GeneratedPdf(
pdf, "inbound-marks-request-123.pdf", 1));
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.requestAttr("requestId", "request-123")
.contentType(MediaType.APPLICATION_JSON)
.content(validRequestJson().replace(
"\"downloadFileName\": \"PCHK2608040174-19件-MCC1.pdf\",", "")))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("inbound-marks-request-123.pdf")));
ArgumentCaptor<InboundMarkPdfDtos.Request> captor =
ArgumentCaptor.forClass(InboundMarkPdfDtos.Request.class);
verify(service).generate(captor.capture());
assertThat(captor.getValue().downloadFileName()).isEqualTo("inbound-marks-request-123.pdf");
}
@Test
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
PostMapping mapping = InternalPdfController.class
.getMethod("generateInboundMarks", String.class, InboundMarkPdfDtos.Request.class,
jakarta.servlet.http.HttpServletRequest.class)
.getAnnotation(PostMapping.class);
assertThat(mapping.produces()).isEmpty();
}
@Test
void openApiDocumentsBothSuccessAndErrorRepresentations() throws Exception {
String contract = new ClassPathResource("openapi/print-internal-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(contract)
.contains("/internal/print/v1/pdfs/inbound-marks:")
.contains("application/pdf:")
.contains("application/json:")
.contains("name: X-Internal-Service-Token")
.contains("maximum: 2000");
}
private String validRequestJson() {
return """
{
"templateCode": "INBOUND_MARK_V1",
"pageSpec": {"widthMm": 100, "heightMm": 100},
"downloadFileName": "PCHK2608040174-19件-MCC1.pdf",
"labels": [{
"topCode": "PCHK2608040174",
"warehouseText": "MCC1",
"cartonText": "CTNS:19-1",
"routeText": "洛杉矶普船海卡",
"qrContent": "PCHK2608040174#19-1"
}]
}
""";
}
}
@@ -0,0 +1,36 @@
package com.baiyue.print.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InternalServiceTokenGuardTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
@Test
void acceptsOnlyConfiguredToken() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard(TOKEN);
guard.verify(TOKEN);
assertThatThrownBy(() -> guard.verify(null))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
assertThatThrownBy(() -> guard.verify("wrong"))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
}
@Test
void refusesUnsafeConfiguration() {
assertThatThrownBy(() -> new InternalServiceTokenGuard("too-short"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("at least 32 bytes");
assertThatThrownBy(() -> new InternalServiceTokenGuard(null))
.isInstanceOf(IllegalStateException.class);
}
}
@@ -0,0 +1,89 @@
package com.baiyue.print.controller;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.print.config.PrintPdfProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
class PrintRequestSizeFilterTest {
private PrintRequestSizeFilter filter;
@BeforeEach
void setUp() {
PrintPdfProperties properties = new PrintPdfProperties();
properties.setMaxRequestBytes(10);
filter = new PrintRequestSizeFilter(properties, new ObjectMapper().findAndRegisterModules());
}
@Test
void passesBoundedBodyToDownstream() throws Exception {
MockHttpServletRequest request = request("1234567890", false);
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicReference<String> received = new AtomicReference<>();
filter.doFilter(request, response, (wrapped, ignored) -> received.set(
new String(wrapped.getInputStream().readAllBytes(), StandardCharsets.UTF_8)));
assertThat(received).hasValue("1234567890");
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest() {
@Override public int getContentLength() { return 11; }
@Override public long getContentLengthLong() { return 11; }
};
initialize(request, "{}");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicReference<Boolean> called = new AtomicReference<>(false);
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> called.set(true));
assertThat(called).hasValue(false);
assertTooLarge(response);
}
@Test
void rejectsOversizedBodyWhenLengthIsUnknown() throws Exception {
MockHttpServletRequest request = request("12345678901", true);
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicReference<Boolean> called = new AtomicReference<>(false);
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> called.set(true));
assertThat(called).hasValue(false);
assertTooLarge(response);
}
private MockHttpServletRequest request(String content, boolean unknownLength) {
MockHttpServletRequest request = unknownLength ? new MockHttpServletRequest() {
@Override public int getContentLength() { return -1; }
@Override public long getContentLengthLong() { return -1; }
} : new MockHttpServletRequest();
initialize(request, content);
return request;
}
private void initialize(MockHttpServletRequest request, String content) {
request.setMethod("POST");
request.setRequestURI("/internal/print/v1/pdfs/inbound-marks");
request.setContent(content.getBytes(StandardCharsets.UTF_8));
request.setAttribute("requestId", "request-123");
request.setAttribute("traceId", "trace-123");
}
private void assertTooLarge(MockHttpServletResponse response) throws Exception {
assertThat(response.getStatus()).isEqualTo(413);
assertThat(response.getContentType()).startsWith("application/json");
assertThat(response.getContentAsString()).contains("\"code\":\"A0400\"")
.contains("Request body exceeds 10 bytes")
.contains("\"requestId\":\"request-123\"");
}
}
@@ -0,0 +1,22 @@
package com.baiyue.print.renderer;
import static org.assertj.core.api.Assertions.assertThat;
import com.google.zxing.common.BitMatrix;
import org.junit.jupiter.api.Test;
class PdfQrRendererTest {
@Test
void encodesSameContentDeterministicallyWithQuietZone() {
PdfQrRenderer renderer = new PdfQrRenderer();
BitMatrix first = renderer.encode("PCHK2608040174#19-1");
BitMatrix second = renderer.encode("PCHK2608040174#19-1");
assertThat(first).isEqualTo(second);
assertThat(first.getWidth()).isEqualTo(first.getHeight()).isGreaterThan(21);
assertThat(first.getEnclosingRectangle()).isNotNull();
assertThat(first.getEnclosingRectangle()[0]).isGreaterThanOrEqualTo(4);
assertThat(first.getEnclosingRectangle()[1]).isGreaterThanOrEqualTo(4);
}
}
@@ -0,0 +1,24 @@
package com.baiyue.print.renderer;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class PdfUnitsTest {
@Test
void convertsOneHundredMillimetersToExactPdfPoints() {
assertThat(PdfUnits.mmToPt(100)).isCloseTo(283.46457f, within(0.0001f));
}
@Test
void convertsTopDownCoordinatesToPdfCoordinates() {
PdfUnits units = new PdfUnits(100);
assertThat(units.topDownMmToPdfY(0)).isCloseTo(PdfUnits.mmToPt(100), within(0.0001f));
assertThat(units.topDownMmToPdfY(100)).isCloseTo(0f, within(0.0001f));
}
private static org.assertj.core.data.Offset<Float> within(float value) {
return org.assertj.core.data.Offset.offset(value);
}
}
@@ -0,0 +1,69 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import com.baiyue.print.renderer.PdfUnits;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
class InboundMarkPdfServiceGenerationTest {
@Test
void generatesOneStrictHundredMillimeterPagePerLabelInRequestOrder() throws IOException {
PrintPdfProperties properties = new PrintPdfProperties();
PdfQrRenderer qrRenderer = new PdfQrRenderer();
InboundMarkPdfService service = new InboundMarkPdfService(
properties,
new PdfGenerationLimiter(properties),
new InboundMarkV1Renderer(qrRenderer),
qrRenderer);
InboundMarkPdfDtos.Request request = new InboundMarkPdfDtos.Request(
"INBOUND_MARK_V1",
new InboundMarkPdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100.0")),
"PCHK2608040174.pdf",
List.of(label(1), label(2)));
InboundMarkPdfService.GeneratedPdf generated = service.generate(request);
assertThat(generated.fileName()).isEqualTo("PCHK2608040174.pdf");
assertThat(generated.pageCount()).isEqualTo(2);
assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
try (PDDocument document = Loader.loadPDF(generated.content())) {
assertThat(document.getNumberOfPages()).isEqualTo(2);
document.getPages().forEach(page -> {
assertThat(page.getRotation()).isZero();
assertThat(page.getMediaBox().getWidth())
.isCloseTo(PdfUnits.mmToPt(100), org.assertj.core.data.Offset.offset(0.001f));
assertThat(page.getMediaBox().getHeight())
.isCloseTo(PdfUnits.mmToPt(100), org.assertj.core.data.Offset.offset(0.001f));
});
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(1);
stripper.setEndPage(1);
assertThat(stripper.getText(document))
.contains("PCHK2608040174", "MCC1", "CTNS:2-1", "洛杉矶普船海卡",
"PCHK2608040174#2-1", "MADE IN CHINA");
stripper.setStartPage(2);
stripper.setEndPage(2);
assertThat(stripper.getText(document)).contains("CTNS:2-2", "PCHK2608040174#2-2");
}
}
private InboundMarkPdfDtos.Label label(int sequence) {
return new InboundMarkPdfDtos.Label(
"PCHK2608040174",
"MCC1",
"CTNS:2-" + sequence,
"洛杉矶普船海卡",
"PCHK2608040174#2-" + sequence);
}
}
@@ -0,0 +1,101 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class InboundMarkPdfServiceValidationTest {
private InboundMarkPdfService service;
@BeforeEach
void setUp() {
PrintPdfProperties properties = new PrintPdfProperties();
service = new InboundMarkPdfService(
properties,
mock(PdfGenerationLimiter.class),
mock(InboundMarkV1Renderer.class),
mock(PdfQrRenderer.class));
}
@Test
void rejectsUnsupportedPageSizeBeforeRendering() {
InboundMarkPdfDtos.Request request = request(label("PCHK2608040174#19-1"));
request = new InboundMarkPdfDtos.Request(
request.templateCode(),
new InboundMarkPdfDtos.PageSpec(new BigDecimal("99"), new BigDecimal("100")),
request.downloadFileName(), request.labels());
assertValidationFailure(request, "pageSpec.widthMm");
}
@Test
void rejectsMoreThanTwoThousandPages() {
InboundMarkPdfDtos.Label label = label("PCHK2608040174#19-1");
InboundMarkPdfDtos.Request request = request(java.util.Collections.nCopies(2001, label));
assertValidationFailure(request, "2000");
}
@Test
void rejectsControlCharactersInDisplayText() {
InboundMarkPdfDtos.Label label = new InboundMarkPdfDtos.Label(
"PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶\n普船海卡", "PCHK2608040174#19-1");
assertValidationFailure(request(label), "labels[0].routeText");
}
@Test
void rejectsQrContentLongerThanThirtyFourAsciiCharacters() {
assertValidationFailure(request(label("A".repeat(35))), "34");
}
@Test
void rejectsNonAsciiQrContent() {
assertValidationFailure(request(label("PCHK#中文")), "printable ASCII");
}
@Test
void rejectsPathLikeDownloadFileName() {
InboundMarkPdfDtos.Request base = request(label("PCHK2608040174#19-1"));
InboundMarkPdfDtos.Request request = new InboundMarkPdfDtos.Request(
base.templateCode(), base.pageSpec(), "folder/labels.pdf", base.labels());
assertValidationFailure(request, "reserved character");
}
private void assertValidationFailure(InboundMarkPdfDtos.Request request, String messagePart) {
assertThatThrownBy(() -> service.generate(request))
.isInstanceOfSatisfying(BizException.class, exception -> {
assertThat(exception.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(exception.getMessage()).contains(messagePart);
});
}
private InboundMarkPdfDtos.Request request(InboundMarkPdfDtos.Label label) {
return request(List.of(label));
}
private InboundMarkPdfDtos.Request request(List<InboundMarkPdfDtos.Label> labels) {
return new InboundMarkPdfDtos.Request(
"INBOUND_MARK_V1",
new InboundMarkPdfDtos.PageSpec(new BigDecimal("100.0"), new BigDecimal("100")),
"labels.pdf",
labels);
}
private InboundMarkPdfDtos.Label label(String qrContent) {
return new InboundMarkPdfDtos.Label(
"PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶普船海卡", qrContent);
}
}
@@ -0,0 +1,30 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.print.config.PrintPdfProperties;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class PdfGenerationLimiterTest {
@Test
void rejectsWhenAllPermitsAreInUseAndReleasesPermitOnClose() {
PrintPdfProperties properties = new PrintPdfProperties();
properties.setMaxConcurrentGenerations(1);
properties.setAcquireTimeout(Duration.ZERO);
PdfGenerationLimiter limiter = new PdfGenerationLimiter(properties);
PdfGenerationLimiter.Permit permit = limiter.acquire();
assertThatThrownBy(limiter::acquire)
.isInstanceOfSatisfying(BizException.class,
exception -> org.assertj.core.api.Assertions.assertThat(exception.getResultCode())
.isEqualTo(ResultCode.TOO_MANY_REQUESTS));
permit.close();
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
// A second acquisition proves that close returned the only permit.
}
}
}