test(file-print): extend booking document regression

This commit is contained in:
2026-08-31 20:56:52 +08:00
parent 0cc4eae209
commit 936412f351
8 changed files with 212 additions and 2 deletions
@@ -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();
}
}
}