|
@@ -11,6 +11,8 @@ import com.payment.platform.common.exception.BusinessException;
|
|
|
import com.payment.platform.common.exception.GlobalExceptionHandler;
|
|
import com.payment.platform.common.exception.GlobalExceptionHandler;
|
|
|
import com.payment.platform.common.response.PageResult;
|
|
import com.payment.platform.common.response.PageResult;
|
|
|
import com.payment.platform.core.security.LoginUser;
|
|
import com.payment.platform.core.security.LoginUser;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
|
|
|
|
|
+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.LsyAreaItemDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyCommitMerchantInfoDTO;
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyCommitMerchantInfoDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
|
|
import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
|
|
@@ -32,19 +34,24 @@ import org.springframework.dao.DataIntegrityViolationException;
|
|
|
import org.springframework.dao.DuplicateKeyException;
|
|
import org.springframework.dao.DuplicateKeyException;
|
|
|
import org.springframework.http.MediaType;
|
|
import org.springframework.http.MediaType;
|
|
|
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
|
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
|
|
|
|
+import org.springframework.mock.web.MockMultipartFile;
|
|
|
import org.springframework.security.access.prepost.PreAuthorize;
|
|
import org.springframework.security.access.prepost.PreAuthorize;
|
|
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
import org.springframework.web.bind.annotation.GetMapping;
|
|
import org.springframework.web.bind.annotation.GetMapping;
|
|
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
import org.springframework.web.bind.annotation.RequestMapping;
|
|
import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
import org.springframework.web.bind.annotation.RequestParam;
|
|
import org.springframework.web.bind.annotation.RequestParam;
|
|
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
|
|
|
|
|
|
|
+import java.io.IOException;
|
|
|
import java.lang.reflect.Method;
|
|
import java.lang.reflect.Method;
|
|
|
import java.lang.reflect.Parameter;
|
|
import java.lang.reflect.Parameter;
|
|
|
import java.lang.reflect.ParameterizedType;
|
|
import java.lang.reflect.ParameterizedType;
|
|
|
import java.lang.reflect.Type;
|
|
import java.lang.reflect.Type;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
import java.util.ArrayList;
|
|
import java.util.ArrayList;
|
|
|
import java.util.Collection;
|
|
import java.util.Collection;
|
|
|
import java.util.LinkedHashSet;
|
|
import java.util.LinkedHashSet;
|
|
@@ -59,6 +66,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
|
import static org.mockito.Mockito.*;
|
|
import static org.mockito.Mockito.*;
|
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
@@ -96,6 +104,20 @@ class LeshouyingOnboardingControllerTest {
|
|
|
/** 省市区只读端点的完整路径(加记 29 §二 裁定 R-T9a 新增的第四个端点)。 */
|
|
/** 省市区只读端点的完整路径(加记 29 §二 裁定 R-T9a 新增的第四个端点)。 */
|
|
|
private static final String AREAS_PATH = PREFIX + "/areas";
|
|
private static final String AREAS_PATH = PREFIX + "/areas";
|
|
|
|
|
|
|
|
|
|
+ // ---- 资质图片上传端点(加记 33)用的夹具 ----
|
|
|
|
|
+
|
|
|
|
|
+ /** 上传端点的完整路径。 */
|
|
|
|
|
+ private static final String UPLOAD_PATH = PREFIX + "/upload-image";
|
|
|
|
|
+
|
|
|
|
|
+ /** 一张「图」的字节。上传这条腿只看**内容原样到达**,与图片是不是真的无关。 */
|
|
|
|
|
+ private static final byte[] IMG_BYTES = "fake-jpeg-bytes".getBytes(StandardCharsets.UTF_8);
|
|
|
|
|
+
|
|
|
|
|
+ /** 文件名 —— 扩展名必须在服务层白名单(jpg/jpeg/png/bmp)内,否则那一格测的是护栏而不是透传。 */
|
|
|
|
|
+ private static final String UPLOAD_NAME = "license.jpg";
|
|
|
|
|
+
|
|
|
|
|
+ /** service 返回的图片地址。断言的是**原样回给调用方**,所以取一个不会与别的字符串碰巧相等的值。 */
|
|
|
|
|
+ private static final String IMG_URL = "https://img.example.invalid/picture/xxx.jpg";
|
|
|
|
|
+
|
|
|
@Mock private LsyMerchantService merchantService;
|
|
@Mock private LsyMerchantService merchantService;
|
|
|
@Mock private LsyOnboardingService onboardingService;
|
|
@Mock private LsyOnboardingService onboardingService;
|
|
|
|
|
|
|
@@ -357,7 +379,7 @@ class LeshouyingOnboardingControllerTest {
|
|
|
/**
|
|
/**
|
|
|
* 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
|
|
* 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
|
|
|
*
|
|
*
|
|
|
- * <p><b>四个端点一起钉</b>(含 {@code /areas}):{@code page} 不带 {@code mchNo},但它同样是
|
|
|
|
|
|
|
+ * <p><b>每个端点一起钉</b>(含 {@code /areas} 与 {@code /upload-image}):{@code page} 不带 {@code mchNo},但它同样是
|
|
|
* 「以租户为范围」的读 —— 一个没有租户的主体在 {@code TenantInnerInterceptor} 下
|
|
* 「以租户为范围」的读 —— 一个没有租户的主体在 {@code TenantInnerInterceptor} 下
|
|
|
* **不会被追加任何谓词**(Step 0 实测第 3 行),拿到的是全平台所有租户的商户映射。所以它也要拒。
|
|
* **不会被追加任何谓词**(Step 0 实测第 3 行),拿到的是全平台所有租户的商户映射。所以它也要拒。
|
|
|
* {@code /areas} 是**全局参考数据**(区划树对每个租户都一样,服务层也不带租户),它挂闸
|
|
* {@code /areas} 是**全局参考数据**(区划树对每个租户都一样,服务层也不带租户),它挂闸
|
|
@@ -684,6 +706,299 @@ class LeshouyingOnboardingControllerTest {
|
|
|
verify(onboardingService).areaList("1", " ");
|
|
verify(onboardingService).areaList("1", " ");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ──────────────── 资质图片上传端点(加记 33 裁定 R-T9c) ────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 权限串**逐字**是 {@code :create}(同 {@link #areasEndpoint_reusesTheCreatePermissionStringVerbatim}
|
|
|
|
|
+ * 的理由:超管直通 ⇒ 「超管跑一遍能过」证明不了任何权限串)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么必须是 {@code :create}】{@code LsyMerchantRequiredFields} 把
|
|
|
|
|
+ * {@code picture_license} 一族放在**多个商户类别的必填清单**里 ⇒ 造不出这些 URL 的进件走不通;
|
|
|
|
|
+ * 而填这些 URL 的能力属于「建号」⇒ 复用既有的 {@code :create},**不新增第五个权限串**
|
|
|
|
|
+ * (Task 8 已交付的菜单 SQL 一个字都不用改、用户也不需要重新执行那份 SQL)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void uploadImageEndpoint_reusesTheCreatePermissionStringVerbatim() throws Exception {
|
|
|
|
|
+ PreAuthorize a = AnnotatedElementUtils.findMergedAnnotation(uploadImageMethod(), PreAuthorize.class);
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(a, "upload-image 端点缺 @PreAuthorize —— 端点会变成任何登录用户可调");
|
|
|
|
|
+ assertEquals("@perm.hasAny('" + LeshouyingOnboardingController.PERM_ONBOARDING_CREATE + "')",
|
|
|
|
|
+ a.value(),
|
|
|
|
|
+ "权限串必须逐字复用 :create(不新增第五个权限串)。实测: " + a.value());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 路由 + 方法 + 「请求是 multipart」。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>{@code multipart} 那一半的判据是**形参类型**:端点收的是一个 {@link MultipartFile}
|
|
|
|
|
+ * 部件(而不是把文件塞进 JSON 体的某个字符串字段里)—— 后者会把二进制内容过一遍
|
|
|
|
|
+ * base64 与 JSON 转义,且**零件名**这件事就无从谈起了。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void uploadImageEndpoint_isPostOnUploadImagePath() throws Exception {
|
|
|
|
|
+ Method upload = uploadImageMethod();
|
|
|
|
|
+
|
|
|
|
|
+ PostMapping methodMapping = AnnotatedElementUtils.findMergedAnnotation(upload, PostMapping.class);
|
|
|
|
|
+ assertNotNull(methodMapping, "upload-image 端点必须是 POST(它写状态:往上流传一张图)");
|
|
|
|
|
+ assertEquals(List.of("/upload-image"), declaredPaths(methodMapping.value(), methodMapping.path()),
|
|
|
|
|
+ "方法级路径必须是 /upload-image。实测: "
|
|
|
|
|
+ + declaredPaths(methodMapping.value(), methodMapping.path()));
|
|
|
|
|
+
|
|
|
|
|
+ RequestMapping classMapping = AnnotatedElementUtils.findMergedAnnotation(
|
|
|
|
|
+ LeshouyingOnboardingController.class, RequestMapping.class);
|
|
|
|
|
+ assertEquals(List.of(PREFIX), declaredPaths(classMapping.value(), classMapping.path()),
|
|
|
|
|
+ "类级前缀(完整路径 = 前缀 + /upload-image)。实测: "
|
|
|
|
|
+ + declaredPaths(classMapping.value(), classMapping.path()));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(1, upload.getParameterCount(), "本端点只收一个部件");
|
|
|
|
|
+ assertEquals(MultipartFile.class, upload.getParameterTypes()[0],
|
|
|
|
|
+ "形参必须是 MultipartFile —— 那才是 multipart 请求的载体。实测: "
|
|
|
|
|
+ + List.of(upload.getParameterTypes()));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 部件名**逐字**是 {@code file}:两半一起钉。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【钉的是注解里的 {@code name},不是形参名】形参可以随便改(Java 侧的事),
|
|
|
|
|
+ * 线上名不能 —— 改了它,前端用 {@code file} 上送就再也绑不上,而现象是「部件不见了」。
|
|
|
|
|
+ * 全仓三处既有 multipart 端点({@code FileController} / {@code ParamsController} /
|
|
|
|
|
+ * {@code AccountController})**无一例外**都写了 {@code name},这里照抄那个形状。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【实测那一半为什么是「反例先行」】只测「用 {@code file} 上送能成功」的话,
|
|
|
|
|
+ * 一个「随便什么部件名都收」的实现也会绿。所以先送一个**别的**部件名,
|
|
|
|
|
+ * 断言 service **一次都没被碰过**({@code verifyNoInteractions} 是一条无歧义的零接触读数),
|
|
|
|
|
+ * 再送 {@code file} 作正对照。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void uploadImageEndpoint_bindsTheWirePartNameFileVerbatim() throws Exception {
|
|
|
|
|
+ Parameter p = uploadImageMethod().getParameters()[0];
|
|
|
|
|
+ // ★ 走 findMergedAnnotation 而不是 p.getAnnotation(...):RequestParam 的 value 与 name 是一对
|
|
|
|
|
+ // @AliasFor,类文件里**只存显式写下的那一个**,另一个读出来是 ""(2026-09-23 实测:
|
|
|
|
|
+ // @RequestParam("file") 经 getAnnotation 读到的 name() 是空串)。合并注解把两者都填上。
|
|
|
|
|
+ RequestParam ann = AnnotatedElementUtils.findMergedAnnotation(p, RequestParam.class);
|
|
|
|
|
+ assertNotNull(ann, "部件形参缺 @RequestParam");
|
|
|
|
|
+ assertEquals("file", ann.value(), "注解的 value 必须逐字是 file。实测: " + ann.value());
|
|
|
|
|
+ assertEquals("file", ann.name(), "注解的 name 必须逐字是 file。实测: " + ann.name());
|
|
|
|
|
+
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+
|
|
|
|
|
+ // 反例先行:别的部件名 ⇒ **不被识别**(这条钉的是注解里的 name,不是形参名)
|
|
|
|
|
+ int wrongPartStatus = mockMvc.perform(multipart(UPLOAD_PATH)
|
|
|
|
|
+ .file(new MockMultipartFile("image", UPLOAD_NAME, "image/jpeg", IMG_BYTES)))
|
|
|
|
|
+ .andReturn().getResponse().getStatus();
|
|
|
|
|
+ assertNotEquals(200, wrongPartStatus,
|
|
|
|
|
+ "用别的部件名上送不得被**受理**。实测 HTTP: " + wrongPartStatus);
|
|
|
|
|
+ verifyNoInteractions(onboardingService);
|
|
|
|
|
+
|
|
|
|
|
+ // 正对照:部件名 file ⇒ 受理(否则上面那条「不被识别」可能只是因为压根没路由到这个方法)
|
|
|
|
|
+ when(onboardingService.uploadImage(any(), any())).thenReturn(IMG_URL);
|
|
|
|
|
+ mockMvc.perform(multipart(UPLOAD_PATH)
|
|
|
|
|
+ .file(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+ verify(onboardingService).uploadImage(any(), eq(UPLOAD_NAME));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 透传:service 收到的第一个实参是**文件的字节**、第二个是
|
|
|
|
|
+ * {@code getOriginalFilename()} 的**原值**,**顺序也对**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>顺序不是形式问题:{@code uploadImage(byte[] bytes, String fileName)} 两个形参类型不同,
|
|
|
|
|
+ * 反着传编译就过不去;这条断言真正钉的是「**没有**在中间做任何加工」——
|
|
|
|
|
+ * 例如把文件名换成固定值、或把字节 base64 之后当字符串传。
|
|
|
|
|
+ * 用两个 captor 而不是 {@code eq(byte[])}:数组的 {@code equals} 是**同一性**比较,
|
|
|
|
|
+ * {@code eq} 在这里恒假。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>字节用 {@code assertArrayEquals} 逐字比,不是比长度 —— 上传这条腿的载荷就是内容本身。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void uploadImageEndpoint_passesBytesAndOriginalFilenameThrough_verbatimAndInOrder() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ when(onboardingService.uploadImage(any(), any())).thenReturn(IMG_URL);
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(multipart(UPLOAD_PATH)
|
|
|
|
|
+ .file(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)))
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<byte[]> bytes = ArgumentCaptor.forClass(byte[].class);
|
|
|
|
|
+ ArgumentCaptor<String> fileName = ArgumentCaptor.forClass(String.class);
|
|
|
|
|
+ // 两个 captor 按**位置**绑定 ⇒ 这一行同时钉住「值逐字」与「顺序(第一个是字节)」
|
|
|
|
|
+ verify(onboardingService).uploadImage(bytes.capture(), fileName.capture());
|
|
|
|
|
+
|
|
|
|
|
+ assertArrayEquals(IMG_BYTES, bytes.getValue(), "文件字节必须原样到达 service");
|
|
|
|
|
+ assertEquals(UPLOAD_NAME, fileName.getValue(), "getOriginalFilename() 的原值必须到达 service");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 应答形状:成功时 {@code data} 就是 service 返回的那个字符串**原样**,
|
|
|
|
|
+ * 且它是**裸字符串**、不是一个对象/数组。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【这一条同时是 GC1 的钉子】上游回的键名是**上游专有**(两查皆否:建表语句里没有那一列、
|
|
|
|
|
+ * 也没有任何对外 DTO/实体把它回给租户 —— 读数见 {@code task-9b-report.md}),
|
|
|
|
|
+ * 而 Global Constraint 1 把「**应答字段名**」明列为射程内。
|
|
|
|
|
+ * **不给这个值起任何键名** ⇒ 这条风险在构造上不存在(不是「起个安全的名字」,是「不起名字」)。
|
|
|
|
|
+ * {@code isString()} 那一条断言就是「它没被包进 Map」的机器证据。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void uploadImageEndpoint_returnsTheServiceStringVerbatim_andNotAnObject() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ when(onboardingService.uploadImage(any(), any())).thenReturn(IMG_URL);
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(multipart(UPLOAD_PATH)
|
|
|
|
|
+ .file(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0))
|
|
|
|
|
+ .andExpect(jsonPath("$.data").isString())
|
|
|
|
|
+ .andExpect(jsonPath("$.data").value(IMG_URL));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ {@code getOriginalFilename()} 回 {@code null} 时**不得 NPE、不得落 500**,
|
|
|
|
|
+ * 且交给 service 的必须是一个**非空白**的文件名。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么 null 是真实可达的】{@code Part.getSubmittedFileName()}({@code MultipartFile}
|
|
|
|
|
+ * {@code getOriginalFilename()} 的实现)在部分客户端 / 构造下**回 null** —— 服务层自己的
|
|
|
|
|
+ * javadoc 就把这条记为「{@code null} 是真实可达的:三-B 多半从
|
|
|
|
|
+ * {@code MultipartFile.getOriginalFilename()} 取,**那个 API 的契约就是可空的**」。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么端点必须补一个缺省值,而不是把 null 交给 service】service 的六道护栏对
|
|
|
|
|
+ * **空白**文件名抛 400(那是**正确**的出口,因为「上传方没给名字」确实是它的输入问题)——
|
|
|
|
|
+ * 但 {@code null} 这一格的成因在**传输层**(客户端没带 filename),
|
|
|
|
|
+ * 我们这边有确定行为可用,就没有理由把它变成一次 500。
|
|
|
|
|
+ * 代价是这一格与「传空串」的行为**不同**(空串仍走 service 的 400)——这是**有意**的:
|
|
|
|
|
+ * 空串是调用方明确给了一个空白名字,{@code null} 是它压根没法给。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void uploadImageEndpoint_nullOriginalFilename_reachesServiceNonBlank_without500() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ when(onboardingService.uploadImage(any(), any())).thenReturn(IMG_URL);
|
|
|
|
|
+
|
|
|
|
|
+ // ★ 用匿名子类把 getOriginalFilename() 强制成 null —— 不能靠
|
|
|
|
|
+ // new MockMultipartFile("file", null, …):那个类的构造器把 null **归一成空串**
|
|
|
|
|
+ // (2026-09-23 实测:传 null 进去、读出来是 "",于是这一格测的是「空串」而不是「null」,
|
|
|
|
|
+ // 而这两种输入在本端点上的行为**不同**,见 uploadImage 的 javadoc)。
|
|
|
|
|
+ // 这里模拟的正是那个契约允许的输入:客户端送来的部件根本没有 filename。
|
|
|
|
|
+ MockMultipartFile noFilename = new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES) {
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public String getOriginalFilename() {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ assertNull(noFilename.getOriginalFilename(), "夹具前提:这个部件的文件名确实是 null");
|
|
|
|
|
+ // 量具正对照(不是产品要求):**不覆写**时同一个构造器把 null 归一成空串 ——
|
|
|
|
|
+ // 上面那个匿名子类因此是必需的,否则这一格悄悄变成「传空串」的用例。
|
|
|
|
|
+ // 若这条哪天红了(Spring 改了 MockMultipartFile),请连同上面那段注释一起更新。
|
|
|
|
|
+ assertEquals("", new MockMultipartFile("file", null, "image/jpeg", IMG_BYTES).getOriginalFilename(),
|
|
|
|
|
+ "MockMultipartFile 把 null 文件名归一成空串(量具的既有行为)");
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(multipart(UPLOAD_PATH).file(noFilename))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<String> fileName = ArgumentCaptor.forClass(String.class);
|
|
|
|
|
+ verify(onboardingService).uploadImage(any(), fileName.capture());
|
|
|
|
|
+ assertNotNull(fileName.getValue(),
|
|
|
|
|
+ "getOriginalFilename() 为 null 时不得把 null 交给 service");
|
|
|
|
|
+ assertFalse(fileName.getValue().isBlank(),
|
|
|
|
|
+ "交给 service 的文件名必须非空白(service 对空白名抛 400,而这一格是我们自己的缺省)。实测: "
|
|
|
|
|
+ + fileName.getValue());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 读字节失败({@code MultipartFile.getBytes()} 抛 {@code IOException})⇒
|
|
|
|
|
+ * **400**(HTTP 200 —— 本仓 {@code BusinessException} 的既有出口),**不是 500**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么这条必须有用例】{@code AccountController} 的同形端点直接
|
|
|
|
|
+ * {@code throws IOException} ⇒ 落 {@code GlobalExceptionHandler} 的 catch-all ⇒
|
|
|
|
|
+ * <b>HTTP 500「服务器内部错误」+ 零信息</b>。那是既存形态(本任务不修它),
|
|
|
|
|
+ * 但**不复制它**:读流失败是**这一次上传**的问题,语义是「重发这一个文件」,
|
|
|
|
|
+ * 不是「服务器崩了」。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【文案为什么也要断言】它经 {@code GlobalExceptionHandler} **原样**回给调用方
|
|
|
|
|
+ * ⇒ Global Constraint 1(文案零上游痕迹)对它成立。这里用 4 个上游特征串做一次**窄**检查
|
|
|
|
|
+ * —— 与 {@code LsyOnboardingServiceTest.assertUploadRejected400} 对
|
|
|
|
|
+ * 服务层那批新文案做的是同一种检查(那里只查「乐收赢」一个词),
|
|
|
|
|
+ * 不是本文件在别处否掉的那种「大词表黑名单」。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void uploadImageEndpoint_unreadableBytes_is400NotServerError() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ MockMultipartFile broken = new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES) {
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public byte[] getBytes() throws IOException {
|
|
|
|
|
+ throw new IOException("模拟读流失败");
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // ★ 必须显式给 UTF-8:应答的 Content-Type 不带 charset(JSON 按规范就是 UTF-8),
|
|
|
|
|
+ // 而无参的 getContentAsString() 会用 ISO-8859-1 解 ⇒ 中文全成问号 ⇒
|
|
|
|
|
+ // 下面那个「不含上游痕迹」的循环会**恒真**(2026-09-23 实测踩到,见 task-9b-report.md)。
|
|
|
|
|
+ String body = mockMvc.perform(multipart(UPLOAD_PATH).file(broken))
|
|
|
|
|
+ .andExpect(status().isOk()) // 本仓 BusinessException 的既有出口(不是 500)
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
|
|
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
|
|
|
+
|
|
|
|
|
+ // 正对照(量具有区分度):我们自己写的那句文案必须**读得出来** —— 它是中文,
|
|
|
|
|
+ // 解码一错它就消失。没有这一条,下面那个循环就是一条恒真的假断言。
|
|
|
|
|
+ assertTrue(body.contains("读取上传内容时出错"),
|
|
|
|
|
+ "应答体没被正确解码(或文案没到达调用方)。实测应答: " + body);
|
|
|
|
|
+ // 词汇表逐字取自 task-9b-brief.md ①-step1 第 8 条的检索式(8 个 token)——
|
|
|
|
|
+ // 与报告里那把量具共用**同一份**词汇表,免得两处各扫各的、覆盖面悄悄分叉。
|
|
|
|
|
+ for (String upstreamTrace : List.of("乐收赢", "gdyfsk", "yfpay", "img_url",
|
|
|
|
|
+ "shoubei", "merchant_no", "rate_code", "leshouying")) {
|
|
|
|
|
+ assertFalse(body.contains(upstreamTrace),
|
|
|
|
|
+ "400 文案里出现了上游痕迹「" + upstreamTrace + "」(Global Constraint 1)。实测应答: " + body);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 读流失败发生在**调 service 之前** ⇒ 一次都没有触达下游
|
|
|
|
|
+ verifyNoInteractions(onboardingService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 上游说成功却**没给图片地址** ⇒ 502,且文案里**不得出现上游那个键名**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么这条用例在**端点**层】那句话经 {@code GlobalExceptionHandler} **原样**回给调用方,
|
|
|
|
|
+ * 而本任务正是它的第一处 HTTP 出口 —— 在端点存在之前,它到不了任何租户眼前。
|
|
|
|
|
+ * 服务层的既有用例({@code LsyOnboardingServiceTest.uploadImage_missingImgUrl_throws})
|
|
|
|
|
+ * 只断言 {@code ex.getCode() == 502}、**不断言文案** ⇒ 「文案里有没有那个键名」在那一层
|
|
|
|
|
+ * 是没有任何用例约束的。这条从 HTTP 出口钉两件事:出口是 502(不是 500、也不是 200),
|
|
|
|
|
+ * 且文案里没有那个键名、而**原语义仍在**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>本用例装配一个**真**的 {@link LsyOnboardingService}(只 mock 掉 {@link LeshouyingClient})
|
|
|
|
|
+ * —— 与 {@link #createEndpoint_persistsRowWithPendingStatus} 用真 {@code LsyMerchantService}
|
|
|
|
|
+ * 同一条理由:要断言的是**服务层产生的那一个字符串**,把服务 mock 掉就把被测对象一起 mock 掉了。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void uploadImageEndpoint_missingImageUrl_is502WithNoUpstreamKeyName() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ LeshouyingClient client = mock(LeshouyingClient.class);
|
|
|
|
|
+ // 上游只回了一个 SUCCESS 信封、没有图片地址 ⇒ 服务层判空 ⇒ 502。
|
|
|
|
|
+ // 这里直接给 map(不走解析器):本用例钉的是**缺键那条分支的出口**,不是解析器
|
|
|
|
|
+ // —— 解析器由 LsyOnboardingServiceTest 全覆盖。
|
|
|
|
|
+ when(client.postMultipart(any(), any(), any(), any(), any()))
|
|
|
|
|
+ .thenReturn(Map.<String, Object>of("result_code", "SUCCESS", "result_msg", "操作成功"));
|
|
|
|
|
+ MockMvc mvc = standaloneMvc(new LeshouyingOnboardingController(merchantService,
|
|
|
|
|
+ new LsyOnboardingService(client, new LeshouyingProperties())));
|
|
|
|
|
+
|
|
|
|
|
+ String body = mvc.perform(multipart(UPLOAD_PATH)
|
|
|
|
|
+ .file(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)))
|
|
|
|
|
+ .andExpect(status().isOk()) // 本仓 BusinessException 的既有出口
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(502))
|
|
|
|
|
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
|
|
|
+
|
|
|
|
|
+ // 词汇表逐字取自 task-9b-brief.md ①-step1 第 8 条的检索式(8 个 token)——
|
|
|
|
|
+ // 与报告里那把量具共用**同一份**词汇表,免得两处各扫各的、覆盖面悄悄分叉。
|
|
|
|
|
+ for (String upstreamTrace : List.of("乐收赢", "gdyfsk", "yfpay", "img_url",
|
|
|
|
|
+ "shoubei", "merchant_no", "rate_code", "leshouying")) {
|
|
|
|
|
+ assertFalse(body.contains(upstreamTrace),
|
|
|
|
|
+ "502 文案里出现了上游痕迹「" + upstreamTrace + "」(Global Constraint 1)。实测应答: " + body);
|
|
|
|
|
+ }
|
|
|
|
|
+ // 原语义必须**还在**:图片确实传上去了 + 不得直接重传。
|
|
|
|
|
+ // 只钉「不含键名」是黑名单式的半个钉子 —— 把整句删掉也能绿,所以这里配一条正向断言。
|
|
|
|
|
+ assertTrue(body.contains("不得当作失败直接重传"),
|
|
|
|
|
+ "502 文案丢了「图片可能已上传成功、不得直接重传」这条行为指令。实测应答: " + body);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ───────────────────────── 权限串与应答字段名(结构性) ─────────────────────────
|
|
// ───────────────────────── 权限串与应答字段名(结构性) ─────────────────────────
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -715,9 +1030,9 @@ class LeshouyingOnboardingControllerTest {
|
|
|
LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
|
|
LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
|
|
|
|
|
|
|
|
List<Method> mapped = mappedMethods();
|
|
List<Method> mapped = mappedMethods();
|
|
|
- assertEquals(4, mapped.size(),
|
|
|
|
|
- "本控制器应恰好四个端点(三个权限串 → 四个端点:第四个 /areas 复用 :create,"
|
|
|
|
|
- + "加记 29 §二 裁定 R-T9a)。实测: "
|
|
|
|
|
|
|
+ assertEquals(5, mapped.size(),
|
|
|
|
|
+ "本控制器应恰好五个端点(三个权限串 → 五个端点:/areas 与 /upload-image 都复用 :create,"
|
|
|
|
|
+ + "加记 29 §二 裁定 R-T9a 与加记 33)。实测: "
|
|
|
+ mapped.stream().map(Method::getName).toList());
|
|
+ mapped.stream().map(Method::getName).toList());
|
|
|
|
|
|
|
|
Set<String> onAnnotations = new LinkedHashSet<>();
|
|
Set<String> onAnnotations = new LinkedHashSet<>();
|
|
@@ -812,6 +1127,11 @@ class LeshouyingOnboardingControllerTest {
|
|
|
return LeshouyingOnboardingController.class.getDeclaredMethod("areas", String.class, String.class);
|
|
return LeshouyingOnboardingController.class.getDeclaredMethod("areas", String.class, String.class);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 资质图片上传端点的方法本身(形参类型建在 {@link MultipartFile} 上 —— 它就是「部件」这一半)。 */
|
|
|
|
|
+ private static Method uploadImageMethod() throws NoSuchMethodException {
|
|
|
|
|
+ return LeshouyingOnboardingController.class.getDeclaredMethod("uploadImage", MultipartFile.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 注解上声明的路径。{@code value} 与 {@code path} 互为别名,而
|
|
* 注解上声明的路径。{@code value} 与 {@code path} 互为别名,而
|
|
|
* {@code AnnotatedElementUtils.findMergedAnnotation} 会把**两者都填上**同一个值
|
|
* {@code AnnotatedElementUtils.findMergedAnnotation} 会把**两者都填上**同一个值
|
|
@@ -917,7 +1237,8 @@ class LeshouyingOnboardingControllerTest {
|
|
|
() -> controller.page(1, 20, null),
|
|
() -> controller.page(1, 20, null),
|
|
|
() -> controller.create(createBody("E1", "示例商户")),
|
|
() -> controller.create(createBody("E1", "示例商户")),
|
|
|
() -> controller.commit(commitBody(MCH_NO)),
|
|
() -> controller.commit(commitBody(MCH_NO)),
|
|
|
- () -> controller.areas("1", null));
|
|
|
|
|
|
|
+ () -> controller.areas("1", null),
|
|
|
|
|
+ () -> controller.uploadImage(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 真服务 + mock mapper:要断言的是**写进去 / 查出去的东西**,不是「调了哪个方法」。 */
|
|
/** 真服务 + mock mapper:要断言的是**写进去 / 查出去的东西**,不是「调了哪个方法」。 */
|