|
|
@@ -3,6 +3,7 @@ package com.payment.platform.module.payment.leshouying.service;
|
|
|
import com.payment.platform.common.exception.BusinessException;
|
|
|
import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
|
|
|
import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
|
|
|
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyShopCreateDTO;
|
|
|
@@ -14,6 +15,7 @@ import org.mockito.ArgumentCaptor;
|
|
|
import org.mockito.Mock;
|
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
import java.util.LinkedHashMap;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
@@ -52,13 +54,29 @@ class LsyOnboardingServiceTest {
|
|
|
/** 测试商户号。A 阶段手工维护的就是这一个(见 {@code LsyMerchantService} 的类 javadoc)。 */
|
|
|
private static final String MCH_NO = "Y52211090004";
|
|
|
|
|
|
+ // ---- Task 6:图片上传(上游 page 196236572)用的夹具 ----
|
|
|
+
|
|
|
+ /** 一张「图」的字节。上传这条腿**只看长度**,内容是什么与本类要钉的东西无关。 */
|
|
|
+ private static final byte[] IMG_BYTES = "fake-jpeg-bytes".getBytes(StandardCharsets.UTF_8);
|
|
|
+
|
|
|
+ /** 文件名,取上游 {@code img_name} 的示例形态({@code 8510_pic_idcard.jpg})。 */
|
|
|
+ private static final String IMG_NAME = "8510_pic_idcard.jpg";
|
|
|
+
|
|
|
+ /** 上游 {@code img_url} 的示例形态。 */
|
|
|
+ private static final String IMG_URL = "http://www.abc.com/picture/xxx.jpg";
|
|
|
+
|
|
|
@Mock private LeshouyingClient client;
|
|
|
|
|
|
+ private LeshouyingProperties props;
|
|
|
+
|
|
|
private LsyOnboardingService service;
|
|
|
|
|
|
@BeforeEach
|
|
|
void setUp() {
|
|
|
- service = new LsyOnboardingService(client);
|
|
|
+ // ★ 不 setter 覆盖任何字段:uploadMaxBytes 取默认值 2 MiB(R-T6-2),
|
|
|
+ // 让这个数字被两条边界用例同时钉住,而不是只活在 javadoc 里。
|
|
|
+ props = new LeshouyingProperties();
|
|
|
+ service = new LsyOnboardingService(client, props);
|
|
|
}
|
|
|
|
|
|
/** 把上游报文原文喂给**真实的**解析器,取它真正会交给服务层的那个 Map 当桩的返回值 */
|
|
|
@@ -823,4 +841,321 @@ class LsyOnboardingServiceTest {
|
|
|
assertEquals(1, shops.size(), "非对象元素不得被映射成一行空门店,实际: " + shops);
|
|
|
assertEquals("861811140412", shops.get(0).getShopNo());
|
|
|
}
|
|
|
+
|
|
|
+ // ==================== Task 6:图片上传(上游 page 196236572) ====================
|
|
|
+ // 路径 /api/info/upload/img;字符串部件 img_name + 文件部件 img_file;返回 img_url。
|
|
|
+ // 本方法**只**做「上游 leg」:不加端点、不落库(三-B 的事)。
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 给 multipart 那条腿打桩。口径与 {@link #stubUpstream} 一致 —— 喂**真实**解析器
|
|
|
+ * ({@code parseApi},因为这是 /api/* 的信封),只是把方法换成了 {@code postMultipart}。
|
|
|
+ * 手搓 {@code Map} 会把解析器交给服务层的三种真实形状({@code JSONArray} /
|
|
|
+ * {@code JSONObject} / {@code JSONNull})全换掉。
|
|
|
+ *
|
|
|
+ * <p>★ 只在**确实要出海**的用例里调用它。四条「不该出海」的用例(超限 / null bytes /
|
|
|
+ * 空文件名 / 控制字符)**一次都不打桩** —— 那不是 {@code verifyNoInteractions} 的要求
|
|
|
+ * (打桩不算交互,见 {@link #createShop_blankRequiredField_throws400} 的注释),
|
|
|
+ * 而是打了桩却没人用,会在扩展收尾阶段以 {@code UnnecessaryStubbingException} 变红。
|
|
|
+ */
|
|
|
+ private void stubMultipart(String rawJson) {
|
|
|
+ when(client.postMultipart(anyString(), anyMap(), anyString(), anyString(), any(byte[].class)))
|
|
|
+ .thenReturn(new LeshouyingResponseParser().parseApi(rawJson));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 一次 {@code postMultipart} 出站调用的实参快照 —— 服务层能负责的最远端。 */
|
|
|
+ private record SentMultipart(String path, Map<String, String> formFields,
|
|
|
+ String fileFieldName, String fileName, byte[] fileBytes) {
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 捕获**唯一**一次 multipart 出站调用(多一次、少一次都红)。 */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private SentMultipart sentMultipart() {
|
|
|
+ ArgumentCaptor<String> path = ArgumentCaptor.forClass(String.class);
|
|
|
+ ArgumentCaptor<Map<String, String>> fields =
|
|
|
+ ArgumentCaptor.forClass((Class<Map<String, String>>) (Class<?>) Map.class);
|
|
|
+ ArgumentCaptor<String> fileFieldName = ArgumentCaptor.forClass(String.class);
|
|
|
+ ArgumentCaptor<String> fileName = ArgumentCaptor.forClass(String.class);
|
|
|
+ ArgumentCaptor<byte[]> fileBytes = ArgumentCaptor.forClass(byte[].class);
|
|
|
+ verify(client, times(1)).postMultipart(path.capture(), fields.capture(),
|
|
|
+ fileFieldName.capture(), fileName.capture(), fileBytes.capture());
|
|
|
+ return new SentMultipart(path.getValue(), fields.getValue(),
|
|
|
+ fileFieldName.getValue(), fileName.getValue(), fileBytes.getValue());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 断言某次 {@code uploadImage} 被**本地**护栏 400 拒掉(「没有出海」由调用方自己
|
|
|
+ * {@code verifyNoInteractions})。三条一起断言:码是 400、文案非空、**文案不含上游名号**。
|
|
|
+ * 后者是 Global Constraint 2 对本 task 新增字符串的要求 —— 这些文案将来会经三-B 的端点
|
|
|
+ * 原样给到上传方。
|
|
|
+ */
|
|
|
+ private void assertUploadRejected400(byte[] bytes, String fileName, String label) {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.uploadImage(bytes, fileName), label + " ⇒ 必须被本地护栏拒掉");
|
|
|
+ assertEquals(400, ex.getCode(), label);
|
|
|
+ assertNotNull(ex.getMessage(), label);
|
|
|
+ assertFalse(ex.getMessage().isBlank(), label + " 的文案不得为空");
|
|
|
+ assertFalse(ex.getMessage().contains("乐收赢"),
|
|
|
+ label + " 的文案出现了上游名号(Global Constraint 2):" + ex.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 出站报文形状:字符串部件只有 {@code img_name}、文件部件的字段名是 {@code img_file}、
|
|
|
+ * 文件部件的 filename 与 {@code img_name} **同源**(都来自同一个 {@code fileName} 形参)。
|
|
|
+ *
|
|
|
+ * <p>★「同源」是**我们的收窄**、不是上游规定:文档把 {@code img_name} 列为必填字符串部件、
|
|
|
+ * 示例值({@code 8510_pic_idcard.jpg})与文件名同形,但**没有**规定二者必须一致;
|
|
|
+ * 真不一致时上游的行为我们不知道(**未实测**)。收窄的理由只是不给调用方两个
|
|
|
+ * 可能写出不一致值的入口。
|
|
|
+ *
|
|
|
+ * <p>★ {@code inst_no} / {@code sign} **不在** {@code formFields} 里 —— 由
|
|
|
+ * {@link LeshouyingClient#postMultipart} 内部补({@code signedPayload})。
|
|
|
+ * 这里钉住键集合**恰好**是 {@code {img_name}}:多一个键就是我们把客户端该补的东西
|
|
|
+ * 又送了一遍,而两个键都进签名原文。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_sendsImgNameAndFile() {
|
|
|
+ stubMultipart(ok(",\"img_url\":\"" + IMG_URL + "\""));
|
|
|
+
|
|
|
+ service.uploadImage(IMG_BYTES, IMG_NAME);
|
|
|
+
|
|
|
+ SentMultipart sent = sentMultipart();
|
|
|
+ assertEquals("/api/info/upload/img", sent.path(), "上游 page 196236572 的路径");
|
|
|
+ assertEquals("img_file", sent.fileFieldName(), "文件部件的字段名(上游参数表:img_file,必填)");
|
|
|
+ assertEquals(IMG_NAME, sent.fileName(), "文件部件的 filename 与 img_name 同一个形参");
|
|
|
+ assertArrayEquals(IMG_BYTES, sent.fileBytes(), "文件内容原样送出");
|
|
|
+ assertEquals(Set.of("img_name"), sent.formFields().keySet(),
|
|
|
+ "字符串部件恰好只有 img_name(inst_no / sign 归客户端补)");
|
|
|
+ assertEquals(IMG_NAME, sent.formFields().get("img_name"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 上游返回的 {@code img_url} 原样带回(返回参数表:{@code result_code} / {@code result_msg} / {@code img_url})。 */
|
|
|
+ @Test
|
|
|
+ void uploadImage_returnsImgUrl() {
|
|
|
+ stubMultipart("{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"img_url\":\"" + IMG_URL + "\"}");
|
|
|
+
|
|
|
+ assertEquals(IMG_URL, service.uploadImage(IMG_BYTES, IMG_NAME));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 上游说成功却**没给可用的 {@code img_url}** ⇒ 抛异常,**绝不让 null 一路往外走**。
|
|
|
+ *
|
|
|
+ * <p>理由与 {@link #createShop_missingShopNo_throws} 同源:调用方拿 null 存库就是一条
|
|
|
+ * **查不回来**的记录 —— 图片在上游**确实传上去了**,本地却没有地址。异常类型同样取
|
|
|
+ * {@link BusinessException}(502):手上只有一份 **SUCCESS** 报文,塞进
|
|
|
+ * {@code LeshouyingApiException} 就成了「乐收赢接口失败[SUCCESS]: 操作成功」这句自相矛盾的话。
|
|
|
+ *
|
|
|
+ * <p>三个输入:缺键、**空串**、**全空白**。后两个不是凑数 —— 只测「缺键」的话,
|
|
|
+ * 「只判 null」与「判空白」两种实现分不开,而上游回 {@code "img_url":" "} 与没回
|
|
|
+ * 一样不可用(口径同 {@code createShop_missingShopNo_throws} 对 {@code shop_no} 的 isBlank)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_missingImgUrl_throws() {
|
|
|
+ for (String json : new String[]{
|
|
|
+ "{\"result_code\":\"SUCCESS\"}",
|
|
|
+ ok(",\"img_url\":\"\""),
|
|
|
+ ok(",\"img_url\":\" \"")}) {
|
|
|
+ stubMultipart(json);
|
|
|
+
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.uploadImage(IMG_BYTES, IMG_NAME), json + " ⇒ 必须抛,不得返回 null/空串");
|
|
|
+ assertEquals(502, ex.getCode(), json);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ **本地**大小护栏必须在**出站之前**:超一字节就 400,且**一次上游调用都没发出**。
|
|
|
+ *
|
|
|
+ * <p>上限取 {@link LeshouyingProperties#getUploadMaxBytes()} 的**默认值**(不 setter 覆盖,
|
|
|
+ * 见 {@code setUp})—— 这样 2 MiB 这个数字被本条(超一字节)与
|
|
|
+ * {@link #uploadImage_exactlyAtLimit_accepted}(恰好等于)**同时**钉住,谁改掉默认值两条一起红。
|
|
|
+ *
|
|
|
+ * <p>{@code verifyNoInteractions} 抓的正是「护栏的位置」:把它挪到 {@code postMultipart}
|
|
|
+ * 之后,本地照样 400(上面那条断言仍然过),但**报文已经出海了** —— 那时只有这条会红。
|
|
|
+ *
|
|
|
+ * <p>★ 上限是**我们**给「上传方向挂起」这个已知残留加的缓解手段,**不是**上游限制:
|
|
|
+ * 上游文档(page 196236572)没有写大小上限。见
|
|
|
+ * {@code LeshouyingClient#postMultipart} javadoc 末尾「已知残留:大文件没有写超时」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_oversize_rejectedLocally() {
|
|
|
+ // 默认值 2 MiB 远在 int 范围内;R-T6-2 明令不覆盖 ⇒ 这里可以直接窄化
|
|
|
+ byte[] tooBig = new byte[(int) props.getUploadMaxBytes() + 1];
|
|
|
+
|
|
|
+ assertUploadRejected400(tooBig, IMG_NAME, "超过上限 1 字节");
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ **闭区间**:恰好等于上限必须放行。
|
|
|
+ *
|
|
|
+ * <p>「超过上限」在中文里既能读成 {@code >} 也能读成 {@code >=},代码只能选一个,
|
|
|
+ * 这条把它钉死在 {@code >}:把上限配成 2 MiB 的人,期望的是「2 MiB 的图能传」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_exactlyAtLimit_accepted() {
|
|
|
+ stubMultipart(ok(",\"img_url\":\"" + IMG_URL + "\""));
|
|
|
+ byte[] exactly = new byte[(int) props.getUploadMaxBytes()];
|
|
|
+
|
|
|
+ assertEquals(IMG_URL, service.uploadImage(exactly, IMG_NAME), "恰好等于上限必须放行");
|
|
|
+
|
|
|
+ assertEquals(exactly.length, sentMultipart().fileBytes().length, "送出去的正是那 N 个字节");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 0 字节 ⇒ 400,且没有出海(空文件不是「一张图」)。同族的正例见 {@link #uploadImage_exactlyAtLimit_accepted}。 */
|
|
|
+ @Test
|
|
|
+ void uploadImage_emptyBytes_rejected() {
|
|
|
+ assertUploadRejected400(new byte[0], IMG_NAME, "0 字节");
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ R-T6-3:{@code bytes == null} 也必须 **400** —— brief 的测试表漏了这一条。
|
|
|
+ *
|
|
|
+ * <p>{@code LeshouyingClient.postMultipart} 入口对 {@code fileBytes == null} 抛的是
|
|
|
+ * {@link IllegalArgumentException}(**程序员错误**语义,而且**不会**被包成
|
|
|
+ * {@code LeshouyingApiException})。本层不拦,它就漏出去,**走哪条路都不好**:
|
|
|
+ * 挂到 {@code LeshouyingOpenController} ⇒ HTTP 200 + code 400,消息里是我们自己的
|
|
|
+ * **内部形参名**原样给到租户;挂到本模块另外四个控制器 ⇒ 落全局兜底 ⇒ HTTP 500 +「服务器内部错误」。
|
|
|
+ * 而这条输入本来就是**上传方的输入**(三-B 从 {@code MultipartFile.getBytes()} 取),
|
|
|
+ * 该变成我们自己的业务前置校验,既不该冒充租户的业务规则、也不该冒充服务器崩溃。
|
|
|
+ *
|
|
|
+ * <p>这**不是**上游要求:上游文档没提,是与 {@code uploadMaxBytes}、控制字符同一族的**我们自己的护栏**。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_nullBytes_rejected() {
|
|
|
+ assertUploadRejected400(null, IMG_NAME, "bytes=null");
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 扩展名白名单({@code jpg/jpeg/png/bmp}):不在其中、或**根本没有后缀** ⇒ 400。
|
|
|
+ *
|
|
|
+ * <p>★ 白名单是我们**收窄**的,不是上游列举的。依据:上游这些字段全是「照片」语义
|
|
|
+ * ({@code picture_*} 十五处、{@code img_file} / {@code img_name}),且文档示例是 {@code .jpg}。
|
|
|
+ *
|
|
|
+ * <p>★ 末尾那个 {@code a.JPG} 是**正对照**,钉的是那个 {@code toLowerCase}:
|
|
|
+ * 去掉大小写归一,上面三条照样绿,**只有它会红**。
|
|
|
+ *
|
|
|
+ * <p>★ 顺序说明:{@code verifyNoInteractions} 必须排在「通过」那一条**之前** ——
|
|
|
+ * 它要求这个 mock 上零交互,而通过的那条**必然产生交互**。先证「全拒且没出海」,再打桩放行。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_disallowedExtension_rejected() {
|
|
|
+ for (String name : new String[]{"evil.exe", "x.jpg.php", "x"}) {
|
|
|
+ assertUploadRejected400(IMG_BYTES, name, "扩展名不在白名单: " + name);
|
|
|
+ }
|
|
|
+ verifyNoInteractions(client);
|
|
|
+
|
|
|
+ stubMultipart(ok(",\"img_url\":\"" + IMG_URL + "\""));
|
|
|
+ assertEquals(IMG_URL, service.uploadImage(IMG_BYTES, "a.JPG"),
|
|
|
+ "大写扩展名必须通过(白名单匹配不区分大小写)");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 文件名含路径分隔符 / {@code ..} ⇒ 400。
|
|
|
+ *
|
|
|
+ * <p>是**拒绝**、不是**清洗后照发**:清洗会让日志里的名字与实际发出去的名字不一致 ——
|
|
|
+ * 排障时看到的名字不是上游收到的那个,那比直接拒更坏。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_fileNameWithPathSeparator_rejected() {
|
|
|
+ for (String name : new String[]{"../../etc/passwd.jpg", "a\\b.jpg"}) {
|
|
|
+ assertUploadRejected400(IMG_BYTES, name, "文件名含路径分隔符: " + name);
|
|
|
+ }
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 文件名 {@code null} / 空串 / 全空白 ⇒ 400,且没有出海。
|
|
|
+ *
|
|
|
+ * <p>★ 这一族必须在**扩展名检查之前**:{@code null} 先在扩展名那段炸就是 **NPE**,不是 400。
|
|
|
+ *
|
|
|
+ * <p>★ 这条是**硬性**的,不是防御性编程:客户端那层虽然也拒 {@code null}
|
|
|
+ * (`postMultipart` 的 {@code requireHeaderSafe}),但它抛的是
|
|
|
+ * {@link IllegalArgumentException}(程序员错误语义),而且**不拒空串与全空白**
|
|
|
+ * —— 它的 javadoc 明写「空串 / 全空白是上游侧校验的活、是调用方(= 本层)的活」。
|
|
|
+ * {@code null} 也不是假想路径:三-B 的 controller 多半从
|
|
|
+ * {@code MultipartFile.getOriginalFilename()} 取,**那个 API 的契约就是可空的**。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_nullOrBlankFileName_rejected() {
|
|
|
+ for (String name : new String[]{null, "", " "}) {
|
|
|
+ assertUploadRejected400(IMG_BYTES, name,
|
|
|
+ "fileName=" + (name == null ? "null" : "\"" + name + "\""));
|
|
|
+ }
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 文件名含 ISO 控制字符 ⇒ 400,且**一次上游调用都没发出**。
|
|
|
+ *
|
|
|
+ * <p>钉的是 **2026-09-22 实测出的 multipart 部件头注入**(不是推测):
|
|
|
+ * {@code FormHttpMessageConverter} 把 {@code fileName} 原样写进 {@code Content-Disposition},
|
|
|
+ * 名里的 CRLF 会在部件头块里**造出一个新的头行**(探针读数
|
|
|
+ * {@code filename="a\r\nX-Evil: 1.jpg"},真 CRLF;裸 LF 同样注入)。
|
|
|
+ *
|
|
|
+ * <p>客户端那层({@code postMultipart} 的 {@code requireHeaderSafe})已经替你兜住了,
|
|
|
+ * 但**两层都要、异常类型不同、理由不同**:客户端抛的是程序员错误语义,而这里的文件名
|
|
|
+ * 来自**上传方**,是**用户输入**,必须变成 {@link BusinessException}(400, 我们自己的中文文案)。
|
|
|
+ *
|
|
|
+ * <p>这同样**不是**上游要求(上游文档没提),是我们的护栏。
|
|
|
+ *
|
|
|
+ * <p>NUL 用八进制转义 {@code \0} 写、不用 {@code \u0000}:后者在**词法分析之前**就会被
|
|
|
+ * Java 的 unicode 转义预处理掉,等于往源码里直接躺一个 NUL —— 同一个字符,但那是编译器
|
|
|
+ * 层面的坑,不是这里要测的东西。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_controlCharInFileName_rejected() {
|
|
|
+ String[][] cases = {
|
|
|
+ {"a\r\nX-Evil: 1.jpg", "CRLF"},
|
|
|
+ {"a\nb.jpg", "裸 LF"},
|
|
|
+ {"a\0b.jpg", "NUL"},
|
|
|
+ {"a\tb.jpg", "tab"},
|
|
|
+ };
|
|
|
+ for (String[] c : cases) {
|
|
|
+ assertUploadRejected400(IMG_BYTES, c[0], "文件名含" + c[1] + "(共 " + c[0].length() + " 字符)");
|
|
|
+ }
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ **正对照**:普通文件名(中文 + 空格)**必须通过**。
|
|
|
+ *
|
|
|
+ * <p>没有这一条,一把**过宽**的控制字符锁(连中文 / 空格也拒)会静默拒掉**所有**真实上传,
|
|
|
+ * 而 {@link #uploadImage_controlCharInFileName_rejected} 照样绿 —— 只验窄不验宽,
|
|
|
+ * 这把锁的**宽度**就没有任何证据。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_ordinaryFilename_accepted() {
|
|
|
+ stubMultipart(ok(",\"img_url\":\"" + IMG_URL + "\""));
|
|
|
+ String name = "工资表 2026-09.jpg";
|
|
|
+
|
|
|
+ assertEquals(IMG_URL, service.uploadImage(IMG_BYTES, name));
|
|
|
+
|
|
|
+ SentMultipart sent = sentMultipart();
|
|
|
+ assertEquals(name, sent.fileName(), "空格与中文都不是 ISO 控制字符,必须原样送出");
|
|
|
+ assertEquals(name, sent.formFields().get("img_name"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 这条是「**不做**」的钉子:长文件名**原样送出,不擅自截断**。
|
|
|
+ *
|
|
|
+ * <p>上游文档**没写** {@code img_name} 能接受的长度上限 ⇒ 我们**不设**上游长度上限,
|
|
|
+ * 也不做「顺手截一下」的保护。截断的失败形态是最坏的一种:上游收到的名字与调用方
|
|
|
+ * 以为的不一致,而且**不报错**。
|
|
|
+ *
|
|
|
+ * <p>(brief 里这条原名 {@code uploadImage_fileNameLengthCapped} —— 那个名字描述的是**变异体**
|
|
|
+ * 而不是要求,按 R-T6-6 改名;断言内容一字未动,仍然必须落 M-5 那条红。)
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void uploadImage_longFileName_notTruncated() {
|
|
|
+ stubMultipart(ok(",\"img_url\":\"" + IMG_URL + "\""));
|
|
|
+ String longName = "x".repeat(300) + ".jpg";
|
|
|
+
|
|
|
+ assertEquals(IMG_URL, service.uploadImage(IMG_BYTES, longName));
|
|
|
+
|
|
|
+ SentMultipart sent = sentMultipart();
|
|
|
+ assertEquals(longName, sent.fileName(), "长文件名必须原样送出,不得截断");
|
|
|
+ assertEquals(longName, sent.formFields().get("img_name"), "img_name 同样不截断");
|
|
|
+ }
|
|
|
}
|