test(file-print): extend booking document regression
This commit is contained in:
@@ -105,7 +105,9 @@ public class BusinessFileStorage {
|
||||
normalized = normalized.substring(normalized.lastIndexOf('/') + 1)
|
||||
.replaceAll("[\\p{Cntrl}]", "").trim();
|
||||
if (normalized.isBlank()) return "attachment";
|
||||
return normalized.length() <= 255 ? normalized : normalized.substring(normalized.length() - 255);
|
||||
int codePoints = normalized.codePointCount(0, normalized.length());
|
||||
if (codePoints <= 255) return normalized;
|
||||
return normalized.substring(normalized.offsetByCodePoints(0, codePoints - 255));
|
||||
}
|
||||
|
||||
private static String sha256(Path path) throws IOException {
|
||||
|
||||
@@ -7,6 +7,8 @@ info:
|
||||
businessType=OCEAN_BOOKING_DOCUMENT、businessId=oceanBookingId。BOOKING_CONFIRMATION与
|
||||
BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。
|
||||
x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]
|
||||
x-lifecycle-owner: FIRSTLEG
|
||||
x-file-object-semantics: IMMUTABLE_CREATE_ONLY
|
||||
paths:
|
||||
/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}:
|
||||
post:
|
||||
|
||||
@@ -18,9 +18,12 @@ class OceanBookingFileContractTest {
|
||||
.contains("description: 订舱customerId")
|
||||
.contains("description: oceanBookingId")
|
||||
.contains("x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]")
|
||||
.contains("x-lifecycle-owner: FIRSTLEG")
|
||||
.contains("x-file-object-semantics: IMMUTABLE_CREATE_ONLY")
|
||||
.contains("任意格式,最大50MiB")
|
||||
.contains("maximum: 52428800")
|
||||
.contains("name: X-Internal-Service-Token", "Cache-Control")
|
||||
.doesNotContain("TRUCK_APPOINTMENT_ATTACHMENT", "/api/platform/", "/api/merchant/");
|
||||
.doesNotContain("TRUCK_APPOINTMENT_ATTACHMENT", "/api/platform/", "/api/merchant/",
|
||||
"Idempotency-Key", "expectedVersion", "replace");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,22 @@ class InternalBusinessFileControllerTest {
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
@Test void unicodeSpecialFileNameUsesUtf8AttachmentDisposition() {
|
||||
String fileName = "订舱 确认 🚢 #[]()+,最终版.xlsx";
|
||||
when(service.download(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 93L))
|
||||
.thenReturn(new InternalBusinessFileService.Download(fileName,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{4}));
|
||||
|
||||
var response = controller.download("internal-token", 10001L,
|
||||
"OCEAN_BOOKING_DOCUMENT", "61", 93L);
|
||||
|
||||
assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment");
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename()).isEqualTo(fileName);
|
||||
assertThat(response.getHeaders().getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION))
|
||||
.contains("filename*=");
|
||||
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() {
|
||||
var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes());
|
||||
assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L,
|
||||
@@ -65,6 +81,9 @@ class InternalBusinessFileControllerTest {
|
||||
assertThatThrownBy(() -> controller.metadata("internal-token", -1L,
|
||||
"CONTAINER_CLEARANCE_DOCUMENT", "41", 81L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能为负数");
|
||||
assertThatThrownBy(() -> controller.download("wrong", 10001L,
|
||||
"OCEAN_BOOKING_DOCUMENT", "61", 81L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -39,6 +45,38 @@ class BusinessFileStorageTest {
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("50MB");
|
||||
}
|
||||
|
||||
@Test void rejectsZeroByteAndAcceptsExactFiftyMibArbitraryBinary() throws Exception {
|
||||
BusinessFileStorage storage = storage();
|
||||
var empty = new MockMultipartFile("file", "empty.bin", "application/octet-stream", new byte[0]);
|
||||
assertThatThrownBy(() -> storage.store(10001L, 61L, empty,
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
|
||||
|
||||
MultipartFile boundary = new SparseMultipartFile("exact-50m.bin", "application/x-booking-binary",
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE);
|
||||
var stored = storage.store(10001L, 61L, boundary,
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
|
||||
|
||||
assertThat(stored.size()).isEqualTo(BusinessFileCategory.CUSTOMS_MAX_SIZE);
|
||||
assertThat(stored.contentType()).isEqualTo("application/x-booking-binary");
|
||||
assertThat(Files.size(root.resolve(stored.objectKey())))
|
||||
.isEqualTo(BusinessFileCategory.CUSTOMS_MAX_SIZE);
|
||||
}
|
||||
|
||||
@Test void preservesUnicodeSpacesAndSpecialCharactersWithoutSplittingSurrogatePairs() {
|
||||
BusinessFileStorage storage = storage();
|
||||
String original = "folder\\" + "超".repeat(280) + " 中文 空格 #[]()+,🚢.xlsx ";
|
||||
var file = new MockMultipartFile("file", original,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1, 2});
|
||||
|
||||
var stored = storage.store(10001L, 61L, file,
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
|
||||
|
||||
assertThat(stored.originalName()).endsWith(" 中文 空格 #[]()+,🚢.xlsx").contains("🚢");
|
||||
assertThat(stored.originalName()).doesNotContain("/", "\\", "\r", "\n");
|
||||
assertThat(stored.originalName().codePointCount(0, stored.originalName().length())).isEqualTo(255);
|
||||
}
|
||||
|
||||
@Test void legacyBusinessFilesRetainRestrictedFormatsAndTenMegabyteLimit() {
|
||||
BusinessFileStorage storage = storage();
|
||||
var file = new MockMultipartFile("file", "archive.zip", "application/zip", new byte[]{1});
|
||||
@@ -54,4 +92,25 @@ class BusinessFileStorageTest {
|
||||
storage.initializeRoot();
|
||||
return storage;
|
||||
}
|
||||
|
||||
private record SparseMultipartFile(String originalFilename, String contentType, long size)
|
||||
implements MultipartFile {
|
||||
@Override public String getName() { return "file"; }
|
||||
@Override public String getOriginalFilename() { return originalFilename; }
|
||||
@Override public String getContentType() { return contentType; }
|
||||
@Override public boolean isEmpty() { return size == 0; }
|
||||
@Override public long getSize() { return size; }
|
||||
@Override public byte[] getBytes() { throw new UnsupportedOperationException(); }
|
||||
@Override public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(new byte[0]);
|
||||
}
|
||||
@Override public void transferTo(File destination) throws IOException {
|
||||
try (RandomAccessFile output = new RandomAccessFile(destination, "rw")) {
|
||||
output.setLength(size);
|
||||
}
|
||||
}
|
||||
@Override public void transferTo(Path destination) throws IOException {
|
||||
transferTo(destination.toFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mockito.*;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
@@ -208,4 +212,34 @@ class InternalBusinessFileServiceTest {
|
||||
verify(storage).read(value.getObjectKey());
|
||||
}
|
||||
|
||||
@Test void concurrentRepeatedUploadsCreateDistinctImmutableObjectsForFirstlegToVersion() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "booking.bin",
|
||||
"application/x-booking", new byte[]{1, 2, 3});
|
||||
when(storage.store(10001L, 61L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/object",
|
||||
"application/x-booking", 3, "a".repeat(64), "booking.bin"));
|
||||
AtomicLong ids = new AtomicLong(200);
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(ids.incrementAndGet());
|
||||
return 1;
|
||||
});
|
||||
var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var firstFuture = executor.submit(() ->
|
||||
service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file));
|
||||
var secondFuture = executor.submit(() ->
|
||||
service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file));
|
||||
|
||||
var first = firstFuture.get();
|
||||
var second = secondFuture.get();
|
||||
assertThat(first.fileId()).isNotEqualTo(second.fileId());
|
||||
assertThat(first.businessId()).isEqualTo("61");
|
||||
assertThat(second.businessId()).isEqualTo("61");
|
||||
verify(files, times(2)).insert(any(FileObjectEntity.class));
|
||||
verify(files, never()).updateById(any(FileObjectEntity.class));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -93,6 +93,19 @@ class InternalPdfControllerTest {
|
||||
verify(service, never()).generate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsFrontendStyleFreightPrintWithoutInternalToken() throws Exception {
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/freight-a4-documents")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
|
||||
.content(validFreightA4RequestJson()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||
.andExpect(jsonPath("$.code").value("A0403"));
|
||||
|
||||
verify(freightA4PdfService, never()).generate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsServiceValidationFailureAsJson() throws Exception {
|
||||
when(service.generate(any())).thenThrow(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.baiyue.print.service;
|
||||
|
||||
import com.baiyue.print.config.PrintPdfProperties;
|
||||
import com.baiyue.print.dto.FreightA4PdfDtos;
|
||||
import com.baiyue.print.renderer.FreightA4Renderer;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class,
|
||||
PdfGenerationLimiter.class, FreightA4Renderer.class})
|
||||
class FreightA4ConcurrentGenerationTest {
|
||||
@Resource private FreightA4PdfService service;
|
||||
|
||||
@Test
|
||||
void generatesBothTemplatesConcurrentlyAsIndependentParseableA4Pdfs() throws Exception {
|
||||
var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var shipping = CompletableFuture.supplyAsync(() -> service.generate(shippingRequest()), executor);
|
||||
var bill = CompletableFuture.supplyAsync(() -> service.generate(billRequest()), executor);
|
||||
CompletableFuture.allOf(shipping, bill).get();
|
||||
|
||||
assertPdf(shipping.get(), "OB-260831-0001-出口货物托运单.pdf");
|
||||
assertPdf(bill.get(), "BL-260831-0001-提单确认件.pdf");
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertPdf(FreightA4PdfService.GeneratedPdf generated, String fileName) throws Exception {
|
||||
assertThat(generated.fileName()).isEqualTo(fileName);
|
||||
assertThat(generated.pageCount()).isEqualTo(1);
|
||||
assertThat(generated.content()).startsWith("%PDF-".getBytes(StandardCharsets.US_ASCII));
|
||||
try (var pdf = Loader.loadPDF(generated.content())) {
|
||||
assertThat(pdf.getNumberOfPages()).isEqualTo(1);
|
||||
assertThat(pdf.getPage(0).getMediaBox().getWidth()).isBetween(595f, 596f);
|
||||
assertThat(pdf.getPage(0).getMediaBox().getHeight()).isBetween(841f, 843f);
|
||||
}
|
||||
}
|
||||
|
||||
private static FreightA4PdfDtos.Request shippingRequest() {
|
||||
return request(FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
|
||||
"OB-260831-0001-出口货物托运单.pdf", Map.of(
|
||||
"toCompany", "订舱代理", "shipper", "SHIPPER", "consignee", "CONSIGNEE",
|
||||
"notifyParty", "NOTIFY", "jobNumber", "OB-260831-0001", "cargoQuantity", "1*40HQ",
|
||||
"portOfLoading", "NINGBO", "portOfDischarge", "LOS ANGELES",
|
||||
"placeOfDelivery", "LOS ANGELES", "totalPackagesInWords", "SAY ONE CONTAINER ONLY."));
|
||||
}
|
||||
|
||||
private static FreightA4PdfDtos.Request billRequest() {
|
||||
return request(FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE,
|
||||
"BL-260831-0001-提单确认件.pdf", Map.of(
|
||||
"shipper", "SHIPPER", "consignee", "CONSIGNEE", "notifyParty", "NOTIFY",
|
||||
"bookingNumber", "BL-260831-0001", "portOfLoading", "NINGBO",
|
||||
"portOfDischarge", "LOS ANGELES", "placeOfDelivery", "LOS ANGELES",
|
||||
"serviceTerm", "CY-CY", "freightTerm", "FREIGHT PREPAID",
|
||||
"totalPackagesInWords", "SAY ONE CONTAINER ONLY."));
|
||||
}
|
||||
|
||||
private static FreightA4PdfDtos.Request request(String template, String fileName,
|
||||
Map<String, String> fields) {
|
||||
var cargo = new FreightA4PdfDtos.CargoRow("N/M", "1", "CONTAINER", "GENERAL CARGO",
|
||||
"1000 KGS", "10 CBM");
|
||||
return new FreightA4PdfDtos.Request(template,
|
||||
new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), fileName,
|
||||
List.of(new FreightA4PdfDtos.Document(fields, List.of(cargo))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user