|
|
@@ -0,0 +1,177 @@
|
|
|
+package com.payment.platform.module.payment.har.controller;
|
|
|
+
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
+import com.payment.platform.common.response.Result;
|
|
|
+import com.payment.platform.module.payment.har.dto.HarRegionVO;
|
|
|
+import com.payment.platform.module.payment.har.dto.HarShopCreateDTO;
|
|
|
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.security.access.prepost.PreAuthorize;
|
|
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 管理端「省市目录」两个只读端点 —— 门店表单的级联选择器吃它们。
|
|
|
+ *
|
|
|
+ * <h2>它为什么必须有(而不是「前端自己存一份省市表」)</h2>
|
|
|
+ * 门店自 2026-10-08 起**必填**省份/市(规范 5.5),而收款链接下单时要按顾客 IP 挑一家
|
|
|
+ * 同地区的门店 —— 也就是说,**不给运营一个能录地区的入口,这个功能就是死的**:
|
|
|
+ * 没有一家门店带地区 ⇒ 每一笔收款链接下单都会以「该地区没开通」拒收。
|
|
|
+ *
|
|
|
+ * <p>两份数据必须同源:前端自己存一份省市表,就会与后端那份(同时也是与顾客 IP 匹配的
|
|
|
+ * 那一份)**各走各的**,某天数据集更新后,运营在页面上选出来的编码会成为「永远挑不中」
|
|
|
+ * 的那种门店 —— 而上游没有门店更新接口,补不上。所以前端吃目录,目录只有一份。
|
|
|
+ *
|
|
|
+ * <h2>为什么单独一个控制器,不塞进 HarShopController</h2>
|
|
|
+ * 那边的 {@code @GetMapping("/{id}")} 会把 {@code /regions} 当成一个 {@code Long} 路径变量
|
|
|
+ * 去解析 ⇒ 结果是一句类型转换的 400,而不是这个目录。路径要独立。
|
|
|
+ *
|
|
|
+ * <h2>权限串为什么是两条</h2>
|
|
|
+ * 见 {@link #contract_pathAndPermissionAreTheOnesTheFrontendCalls()}。
|
|
|
+ *
|
|
|
+ * <h2>不碰库、不涉租户</h2>
|
|
|
+ * 目录是打进包里的内存快照({@link HarRegionCatalog}),两个端点**一次库操作都没有** ——
|
|
|
+ * 所以既不需要租户上下文,也不在 {@code TenantInnerInterceptor} 的管辖范围内。
|
|
|
+ */
|
|
|
+class HarRegionControllerTest {
|
|
|
+
|
|
|
+ /** 真目录:它就是这个端点的数据源,mock 掉等于把被测行为换成测试自己写的剧本 */
|
|
|
+ private final HarRegionCatalog catalog = new HarRegionCatalog();
|
|
|
+ private final HarRegionController controller = new HarRegionController(catalog);
|
|
|
+
|
|
|
+ /** 按码取名字;取不到就把**实际拿到的码**全打出来 —— 否则失败信息只有一句「没有 32」 */
|
|
|
+ private static String nameOf(List<HarRegionVO> rows, String code) {
|
|
|
+ return rows.stream()
|
|
|
+ .filter(v -> code.equals(v.getCode()))
|
|
|
+ .map(HarRegionVO::getName)
|
|
|
+ .findFirst()
|
|
|
+ .orElseThrow(() -> new AssertionError("目录里没有 " + code
|
|
|
+ + ";实际拿到的码=" + rows.stream().map(HarRegionVO::getCode).toList()));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────── ① 省 ───────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void provinces_returnsTheWholeMainlandCatalog() {
|
|
|
+ Result<List<HarRegionVO>> r = controller.provinces();
|
|
|
+ List<HarRegionVO> rows = r.getData();
|
|
|
+
|
|
|
+ assertEquals(0, r.getCode(), "0 = 成功(Result.ok 的口径)");
|
|
|
+ assertEquals(31, rows.size(),
|
|
|
+ "省级 31 个。港澳台不在内是有意的:ipwho.is 对它们的 country_code 不是 CN,"
|
|
|
+ + "解析那一步就先判掉了(见 HarIpRegionResolver 的 javadoc)");
|
|
|
+ // 抽三个点:首都、本功能最常用的那个省、名字最长的一个(自治区的归一化专挑它)
|
|
|
+ assertEquals("北京市", nameOf(rows, "11"));
|
|
|
+ assertEquals("江苏省", nameOf(rows, "32"));
|
|
|
+ assertEquals("新疆维吾尔自治区", nameOf(rows, "65"));
|
|
|
+ assertTrue(rows.stream().allMatch(v -> v.getCode() != null && v.getCode().length() == 2),
|
|
|
+ "两位省码是 GB/T 2260 的形状;选择器回填的就是这一格,缺一个字符都拼不上");
|
|
|
+ assertTrue(rows.stream().allMatch(v -> v.getName() != null && !v.getName().isBlank()),
|
|
|
+ "名字要直接上屏,空名字在下拉里是一个看不见的空行");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────── ② 市 ───────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void cities_returnsOnlyThatProvincesCities() {
|
|
|
+ Result<List<HarRegionVO>> r = controller.cities("32");
|
|
|
+ List<HarRegionVO> rows = r.getData();
|
|
|
+
|
|
|
+ assertEquals(0, r.getCode());
|
|
|
+ assertEquals(13, rows.size(), "江苏 13 个地级");
|
|
|
+ assertEquals("南京市", nameOf(rows, "3201"));
|
|
|
+ assertTrue(rows.stream().allMatch(v -> v.getCode().length() == 4),
|
|
|
+ "四位市码");
|
|
|
+ assertTrue(rows.stream().allMatch(v -> v.getCode().startsWith("32")),
|
|
|
+ "返回的必须是**该省**的市:这是「按省取市」这件事本身");
|
|
|
+
|
|
|
+ // ★ 正对照:3401(合肥)在目录里确实存在 —— 少了这一句,上面那条否定断言
|
|
|
+ // 在「目录里根本没有 3401」时也会绿(那就是一条空断言)。
|
|
|
+ assertTrue(catalog.cities("34").stream().anyMatch(c -> "3401".equals(c.code())),
|
|
|
+ "正对照:3401 是安徽的合肥市,目录里有");
|
|
|
+ assertFalse(rows.stream().anyMatch(v -> "3401".equals(v.getCode())),
|
|
|
+ "安徽的市不许出现在江苏的列表里");
|
|
|
+
|
|
|
+ // 空白是查询串里最常见的手滑(手填 URL / 复制粘贴带空格)—— 认得出就别报 400
|
|
|
+ assertEquals(13, controller.cities(" 32 ").getData().size(),
|
|
|
+ "带空格的省码要先 trim 再查表,不能直接拿去比对");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────── ③ 参数不对就 400 ───────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 省码不认识 ⇒ 400。**不返回空表**:空表在下拉里与「这个省真的没有市」长得一样,
|
|
|
+ * 而这里只有一种可能 —— 调用方传了个我们目录里没有的码(写错了、或前端存了一份旧表)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void cities_unknownProvinceCode_400_notAnEmptyList() {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class, () -> controller.cities("99"));
|
|
|
+
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
+ assertEquals("省份编码不存在: 99", ex.getMessage(),
|
|
|
+ "措辞与 HarShopService#requireKnownCity 同源 —— 同一类错误在两处该是同一句话");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void cities_blankProvinceCode_400() {
|
|
|
+ assertEquals(400, assertThrows(BusinessException.class, () -> controller.cities(null)).getCode());
|
|
|
+ assertEquals(400, assertThrows(BusinessException.class, () -> controller.cities(" ")).getCode(),
|
|
|
+ "空白串不能当成一个「码」去查表(空格不是省码,查不到也不该静默回空表)");
|
|
|
+ assertEquals("省份编码不能为空",
|
|
|
+ assertThrows(BusinessException.class, () -> controller.cities(null)).getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────── ④ 接口形状(契约) ───────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 前端要调的就是这两个形状,所以路径与方法级授权得有个钉住的地方。
|
|
|
+ *
|
|
|
+ * <p>★ 权限串**两条都写进 hasAny**:{@code shop:list}(能看门店页的人)与
|
|
|
+ * {@code shop:create}(在建店表单里要用下拉的人)。只写 {@code :create} 的话,
|
|
|
+ * 「能看门店页但不建店」的账号打开页面同样是 403 —— 而这两个串在菜单 SQL 里
|
|
|
+ * 本来就是一起下发的({@code db/har-menu.sql}),多写一条只是把那个例外兜住。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void contract_pathAndPermissionAreTheOnesTheFrontendCalls() throws Exception {
|
|
|
+ RequestMapping rm = HarRegionController.class.getAnnotation(RequestMapping.class);
|
|
|
+ assertNotNull(rm, "类级 @RequestMapping 不得为空 —— 真实路径拼不出来");
|
|
|
+ assertEquals("/payment/har/region", rm.value()[0]);
|
|
|
+
|
|
|
+ Method provinces = HarRegionController.class.getMethod("provinces");
|
|
|
+ GetMapping gp = provinces.getAnnotation(GetMapping.class);
|
|
|
+ assertNotNull(gp, "省列表必须是 GET");
|
|
|
+ assertEquals(List.of("/provinces"), List.of(gp.value()));
|
|
|
+
|
|
|
+ Method cities = HarRegionController.class.getMethod("cities", String.class);
|
|
|
+ GetMapping gc = cities.getAnnotation(GetMapping.class);
|
|
|
+ assertNotNull(gc, "市列表必须是 GET");
|
|
|
+ assertEquals(List.of("/cities"), List.of(gc.value()));
|
|
|
+
|
|
|
+ // ★ 拼出来的是前端真正要调的那两条(分开断言会在「有人把两段一起改掉」时照样绿)
|
|
|
+ assertEquals("/payment/har/region/provinces", rm.value()[0] + gp.value()[0]);
|
|
|
+ assertEquals("/payment/har/region/cities", rm.value()[0] + gc.value()[0]);
|
|
|
+
|
|
|
+ PreAuthorize pa = provinces.getAnnotation(PreAuthorize.class);
|
|
|
+ PreAuthorize pc = cities.getAnnotation(PreAuthorize.class);
|
|
|
+ assertNotNull(pa, "端点必须挂权限串:不挂就是「登录即可」,任何账号都能读到内部编码表");
|
|
|
+ assertNotNull(pc);
|
|
|
+ // 逐字钉死(不是 contains):串写错是**静默的** —— 超管直通,拿超管跑的用例一条都测不出,
|
|
|
+ // 而普通账号会全线 403,那正好是「门店建不出来」的形态。
|
|
|
+ assertEquals("@perm.hasAny('module_payment:har:shop:list','module_payment:har:shop:create')",
|
|
|
+ pa.value());
|
|
|
+ assertEquals(pa.value(), pc.value(),
|
|
|
+ "两个端点同一条串 —— 选择器的两跳要么都通、要么都不通,半通的下拉框最难查");
|
|
|
+
|
|
|
+ // 其中一条必须与「创建门店」逐字相同(复用既有权限点,不新增菜单 SQL)
|
|
|
+ PreAuthorize shopCreate = HarShopController.class
|
|
|
+ .getMethod("create", HarShopCreateDTO.class)
|
|
|
+ .getAnnotation(PreAuthorize.class);
|
|
|
+ assertNotNull(shopCreate);
|
|
|
+ assertEquals("@perm.hasAny('module_payment:har:shop:create')", shopCreate.value());
|
|
|
+ }
|
|
|
+}
|