You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
259 lines
13 KiB
259 lines
13 KiB
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 基线**逐项对账。
|
|
*
|
|
* <p><strong>期望值来源</strong>:{@code src/test/resources/r01b/error-code-http-status-baseline.tsv},
|
|
* 该文件由独立复验者从 {@code git show HEAD:…GlobalExceptionHandler.java} 的
|
|
* {@code buildBusinessHttpStatuses()} 提取生成,<em>不</em>复用工作区实现的任何映射表,
|
|
* 因此不构成同源假证明。</p>
|
|
*/
|
|
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<String, int[]> baseline() {
|
|
// 用有序结构保存 name -> {code, statusOrdinal}
|
|
Map<String, int[]> 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<String, int[]> 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<String, int[]> 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<ApiResponse<Void>> ra = handler.handleBusinessException(a, request());
|
|
ResponseEntity<ApiResponse<Void>> 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<ErrorCode, HttpStatus> 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<ErrorCode, HttpStatus> 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<ErrorCode> 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<String, int[]> 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<ApiResponse<Void>> 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;
|
|
}
|
|
}
|
|
|