package com.mydreamjob.common.r01b; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; import com.mydreamjob.common.exception.GlobalExceptionHandler; import com.mydreamjob.common.logging.RequestIdAccessor; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockHttpServletRequest; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * R01-B 独立复验:HTTP 映射与 **HEAD 基线**逐项对账。 * *

期望值来源:{@code src/test/resources/r01b/error-code-http-status-baseline.tsv}, * 该文件由独立复验者从 {@code git show HEAD:…GlobalExceptionHandler.java} 的 * {@code buildBusinessHttpStatuses()} 提取生成,不复用工作区实现的任何映射表, * 因此不构成同源假证明。

*/ class R01BIndependentHttpMappingParityTest { private static final String BASELINE = "/r01b/error-code-http-status-baseline.tsv"; private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), registry); /** 从 HEAD 基线资源读取:ErrorCode 名称 → 数字码 → HEAD 状态。 */ private static Map baseline() { // 用有序结构保存 name -> {code, statusOrdinal} Map rows = new LinkedHashMap<>(); try (InputStream in = R01BIndependentHttpMappingParityTest.class.getResourceAsStream(BASELINE)) { assertThat(in).as("基线资源必须存在: %s", BASELINE).isNotNull(); try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { String line; while ((line = r.readLine()) != null) { if (line.isBlank() || line.startsWith("#")) continue; String[] parts = line.split("\t"); rows.put(parts[0], new int[]{Integer.parseInt(parts[1]), statusOf(parts[2]).value()}); } } } catch (Exception e) { throw new IllegalStateException("failed to read baseline", e); } return rows; } private static HttpStatus statusOf(String token) { return switch (token) { case "FORBIDDEN" -> HttpStatus.FORBIDDEN; case "NOT_FOUND" -> HttpStatus.NOT_FOUND; case "CONFLICT" -> HttpStatus.CONFLICT; case "TOO_MANY_REQUESTS" -> HttpStatus.TOO_MANY_REQUESTS; case "UNAUTHORIZED" -> HttpStatus.UNAUTHORIZED; default -> HttpStatus.BAD_REQUEST; }; } // ── 166 项逐项对账 ──────────────────────────────────────── @Test @DisplayName("独立复验:166 个枚举常量按名称逐项与 HEAD 基线对齐(不按数字去重)") void everyEnumConstantMatchesHeadBaselineByName() { Map base = baseline(); // 169 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 168 条 assertThat(base).hasSize(169); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); assertThat(ErrorCode.values()).hasSize(169); int checked = 0; for (ErrorCode code : ErrorCode.values()) { int[] expected = base.get(code.name()); assertThat(expected).as("基线必须覆盖 %s", code.name()).isNotNull(); assertThat(code.getCode()).as("%s 的数字码不得改变", code.name()).isEqualTo(expected[0]); assertThat(registry.resolve(code).value()) .as("%s 的 HTTP 状态必须与 HEAD 一致", code.name()) .isEqualTo(expected[1]); checked++; } assertThat(checked).isEqualTo(169); } @Test @DisplayName("密码登录:116 个显式映射 + 53 个默认 400") void explicitAndDefaultCountsMatchBaseline() { Map base = baseline(); long explicitInBaseline = base.values().stream().filter(v -> v[1] != 400).count(); assertThat(explicitInBaseline).isEqualTo(115); assertThat(base.size() - explicitInBaseline).isEqualTo(54); assertThat(registry.explicitMappingCount()).isEqualTo(115); assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54); // 115 个常量仅覆盖 114 个唯一数字码(9103 共用) assertThat(registry.explicitNumericCodeCount()).isEqualTo(114); } @Test @DisplayName("独立复验:关键既有语义逐项保持") void keySemanticsPreserved() { assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(registry.resolve(ErrorCode.CREDENTIALS_INVALID)).isEqualTo(HttpStatus.UNAUTHORIZED); assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN); assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(registry.resolve(ErrorCode.USER_NOT_FOUND)).isEqualTo(HttpStatus.NOT_FOUND); assertThat(registry.resolve(ErrorCode.APPLICATION_DUPLICATE)).isEqualTo(HttpStatus.CONFLICT); assertThat(registry.resolve(ErrorCode.ABILITY_RETAKE_LIMIT)).isEqualTo(HttpStatus.TOO_MANY_REQUESTS); } // ── 9103 重复码 ─────────────────────────────────────────── @Test @DisplayName("独立复验:9103 的两个枚举常量都显式注册,且分别解析为 409") void bothSharedNumericCodeConstantsAreExplicitlyMappedTo409() { assertThat(ErrorCode.REVIEW_ALREADY_DECIDED.getCode()).isEqualTo(9103); assertThat(ErrorCode.DUPLICATE_FEEDBACK.getCode()).isEqualTo(9103); assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.REVIEW_ALREADY_DECIDED)).isTrue(); assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.DUPLICATE_FEEDBACK)).isTrue(); assertThat(registry.resolve(ErrorCode.REVIEW_ALREADY_DECIDED)).isEqualTo(HttpStatus.CONFLICT); assertThat(registry.resolve(ErrorCode.DUPLICATE_FEEDBACK)).isEqualTo(HttpStatus.CONFLICT); } @Test @DisplayName("独立复验:运行时以数字码解析,两个 9103 常量不可区分(如实记录,不夸大)") void runtimeCannotDistinguishTheTwoSharedConstants() { // BusinessException 只保存 int,枚举身份在运行时丢失 BusinessException a = new BusinessException(ErrorCode.REVIEW_ALREADY_DECIDED); BusinessException b = new BusinessException(ErrorCode.DUPLICATE_FEEDBACK); assertThat(a.getErrorCode()).isEqualTo(b.getErrorCode()).isEqualTo(9103); // 数字入口对两者给出同一结果:当前两者同为 409,故 HTTP 行为一致 assertThat(registry.resolve(9103)).isEqualTo(HttpStatus.CONFLICT); assertThat(a.getErrorCode()).isEqualTo(b.getErrorCode()); // Handler 也只拿到数字码,响应中的 code 相同 ResponseEntity> ra = handler.handleBusinessException(a, request()); ResponseEntity> rb = handler.handleBusinessException(b, request()); assertThat(ra.getStatusCode()).isEqualTo(rb.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); assertThat(ra.getBody().getCode()).isEqualTo(rb.getBody().getCode()).isEqualTo(9103); } @Test @DisplayName("独立复验:同数字码被映射到不同状态时初始化必须失败,不得静默任选") void conflictingStatusForSharedNumericCodeFailsFast() throws Exception { // 通过公开 Builder 构造"同数字、不同状态"的映射 Map conflicting = new ErrorHttpStatusRegistry.Builder() .put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) .put(HttpStatus.NOT_FOUND, ErrorCode.DUPLICATE_FEEDBACK) .build(); assertThat(conflicting).hasSize(2); // 数字索引构建(真实注册表在静态初始化时走同一条路径)必须拒绝该冲突 Method indexByCode = ErrorHttpStatusRegistry.class .getDeclaredMethod("indexByCode", Map.class); indexByCode.setAccessible(true); assertThatThrownBy(() -> { try { indexByCode.invoke(null, conflicting); } catch (java.lang.reflect.InvocationTargetException e) { throw e.getCause(); } }) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("conflicting HTTP status mapping") .hasMessageContaining("9103"); // 反证:状态相同时允许,且数字入口唯一 Map same = new ErrorHttpStatusRegistry.Builder() .put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) .put(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_FEEDBACK) .build(); assertThat(same).hasSize(2); } @Test @DisplayName("独立复验:重复注册同一枚举常量(即使状态相同)初始化即失败") void duplicateEnumKeyFailsAtRegistration() { assertThatThrownBy(() -> new ErrorHttpStatusRegistry.Builder() .put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED) .put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("duplicate HTTP status mapping"); } @Test @DisplayName("独立复验:映射不依赖枚举声明顺序") void mappingDoesNotDependOnEnumDeclarationOrder() { // 取一个映射区段,正序与逆序注册结果必须相同 List notFound = List.of( ErrorCode.USER_NOT_FOUND, ErrorCode.JOB_NOT_FOUND, ErrorCode.COMPANY_NOT_FOUND); ErrorHttpStatusRegistry.Builder asc = new ErrorHttpStatusRegistry.Builder(); for (ErrorCode c : notFound) asc.put(HttpStatus.NOT_FOUND, c); ErrorHttpStatusRegistry.Builder desc = new ErrorHttpStatusRegistry.Builder(); for (int i = notFound.size() - 1; i >= 0; i--) desc.put(HttpStatus.NOT_FOUND, notFound.get(i)); assertThat(asc.build()).isEqualTo(desc.build()); // 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明 Map base = baseline(); for (ErrorCode c : ErrorCode.values()) { assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]); } } @Test @DisplayName("独立复验:未知数字码仍为 400,且处理器响应不含堆栈或内部类名") void unknownCodeFallsBackTo400AndResponseIsClean() { assertThat(registry.resolve(0)).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(registry.resolve(-1)).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(registry.resolve(123456789)).isEqualTo(HttpStatus.BAD_REQUEST); for (ErrorCode code : new ErrorCode[]{ ErrorCode.FORBIDDEN, ErrorCode.USER_NOT_FOUND, ErrorCode.APPLICATION_DUPLICATE, ErrorCode.UNAUTHORIZED, ErrorCode.SERVICE_UNAVAILABLE, ErrorCode.DUPLICATE_FEEDBACK}) { MockHttpServletRequest request = request(); request.setRequestURI("/r01b/independent/parity"); ResponseEntity> response = handler.handleBusinessException(new BusinessException(code), request); assertThat(response.getBody()).isNotNull(); String text = response.getBody().toString(); assertThat(text) .as("%s 响应体", code.name()) .doesNotContain("Exception") .doesNotContain("at com.mydreamjob") .doesNotContain("GlobalExceptionHandler") .doesNotContain("ErrorHttpStatusRegistry") .doesNotContain("/r01b/independent/parity"); assertThat(response.getBody().getRequestId()).isEqualTo("r01b-independent"); } } private static MockHttpServletRequest request() { MockHttpServletRequest request = new MockHttpServletRequest(); request.setRequestURI("/r01b/independent"); request.setAttribute("requestId", "r01b-independent"); return request; } }