打印服务
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+101
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user