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.
 
 
 

222 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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* R01-B/B2:重构前后 HTTP 映射**逐项一致**证明。
*
* <p>这里的四个状态桶是**重构前**从 {@code GlobalExceptionHandler.buildBusinessHttpStatuses()}
* 逐条抄录的独立快照(亦保存于 {@code docs/acceptance/r01-b/b2-mapping-baseline.json}),
* 不是从重构后的实现反推。任何一个错误码的状态发生漂移,本测试都会失败。</p>
*/
class GlobalExceptionHandlerMappingParityTest {
private static final Set<String> FORBIDDEN = Set.of(
"APPLICATION_OWNER_MISMATCH", "FORBIDDEN", "INTERVIEW_ACCESS_DENIED",
"MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER");
private static final Set<String> NOT_FOUND = Set.of(
"ABILITY_ATTEMPT_NOT_FOUND", "ABILITY_RESULT_NOT_FOUND", "ABILITY_TYPE_NOT_FOUND",
"APPEAL_NOT_FOUND", "APPLICATION_NOT_FOUND", "ASSESSMENT_ATTEMPT_NOT_FOUND",
"ASSESSMENT_TYPE_NOT_FOUND", "COMPANY_NOT_FOUND", "CONVERSATION_NOT_FOUND",
"DATA_EXPORT_TASK_NOT_FOUND", "FEEDBACK_NOT_FOUND", "INTERVIEW_SCORE_NOT_FOUND",
"JOB_NOT_FOUND", "MESSAGE_NOT_FOUND", "OFFER_COUNTER_OFFER_NOT_FOUND",
"OFFER_NOT_FOUND", "PROFILE_NOT_FOUND", "REPORT_NOT_FOUND",
"RESUME_EXPORT_TASK_NOT_FOUND", "RESUME_VERSION_NOT_FOUND", "REVIEW_NOT_FOUND",
"REVIEW_REPLY_NOT_FOUND", "RISK_EVENT_NOT_FOUND", "RISK_RULE_NOT_FOUND",
"TALENT_INVITATION_NOT_FOUND", "USER_NOT_FOUND", "VERIFICATION_NOT_FOUND");
private static final Set<String> CONFLICT = Set.of(
"ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE",
"ABILITY_DUPLICATE_ACTIVE", "ABILITY_IDEMPOTENCY_CONFLICT",
"ABILITY_RETAKE_COOLDOWN", "ACCOUNT_DELETED",
"ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED", "ACCOUNT_DELETION_CONCURRENT_UPDATE",
"ACCOUNT_DELETION_PENDING", "APPEAL_ALREADY_DECIDED", "APPEAL_DUPLICATE",
"APPEAL_IDEMPOTENCY_CONFLICT", "APPEAL_INVALID_TRANSITION",
"APPEAL_ROLLBACK_LIMIT_EXCEEDED", "APPLICATION_DUPLICATE",
"APPLICATION_NOT_MATCHED", "ASSESSMENT_ATTEMPT_INVALID_STATE",
"ASSESSMENT_CONCURRENT_UPDATE", "ASSESSMENT_DUPLICATE_ACTIVE",
"ASSESSMENT_IDEMPOTENCY_CONFLICT", "COMPANY_ALREADY_EXISTS",
"DATA_EXPORT_DUPLICATE", "DATA_EXPORT_EXPIRED", "DATA_EXPORT_FAILED",
"DATA_EXPORT_FILE_NOT_FOUND", "DATA_EXPORT_NOT_READY",
"DEFAULT_RESUME_DELETE_NOT_ALLOWED", "DUPLICATE_DEFAULT_RESUME",
"DUPLICATE_FEEDBACK", "DUPLICATE_OFFER", "DUPLICATE_REVIEW",
"INTERVIEW_ALREADY_RESPONDED", "INTERVIEW_COMPLETE_NOT_READY",
"INTERVIEW_NOT_COMPLETED", "INTERVIEW_SCORE_CONCURRENT_UPDATE",
"INTERVIEW_SCORE_SUBMITTED", "INVALID_APPLICATION_STATUS_TRANSITION",
"INVALID_DELETION_STATE", "INVALID_INTERVIEW_STATUS_TRANSITION",
"INVALID_JOB_STATUS_TRANSITION", "INVALID_OFFER_STATUS_TRANSITION",
"INVALID_VERIFICATION_STATUS_TRANSITION", "LAST_ADMIN_CANNOT_DELETE",
"MATCH_CLOSED", "MATCH_SCORES_LOCKED", "MESSAGE_RECALL_CONCURRENT_UPDATE",
"MESSAGE_RECALL_WINDOW_EXPIRED", "OFFER_COUNTER_OFFER_EXISTS",
"OFFER_COUNTER_OFFER_EXPIRED", "OFFER_COUNTER_OFFER_NOT_PENDING", "OFFER_EXPIRED",
"OFFER_NOT_EDITABLE", "OFFER_REMINDER_DUPLICATE",
"PRIVACY_SETTINGS_CONCURRENT_UPDATE", "REPORT_ALREADY_DECIDED", "REPORT_DUPLICATE",
"REPORT_IDEMPOTENCY_CONFLICT", "REPORT_INVALID_TRANSITION",
"RESUME_EXPORT_IN_PROGRESS", "RESUME_EXPORT_NOT_READY", "REVIEW_ALREADY_DECIDED",
"REVIEW_REPLY_ALREADY_EXISTS", "REVIEW_REPLY_CONCURRENT_UPDATE",
"REVIEW_REPLY_IDEMPOTENCY_CONFLICT", "REVIEW_REPLY_INVALID_STATE",
"REVIEW_WEIGHT_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE",
"RISK_INVALID_ACTION", "RISK_RULE_CONCURRENT_UPDATE",
"TALENT_INVITATION_ALREADY_ACTIVE", "TALENT_INVITATION_CANDIDATE_UNAVAILABLE",
"TALENT_INVITATION_CONCURRENT_UPDATE", "TALENT_INVITATION_EXPIRED",
"TALENT_INVITATION_IDEMPOTENCY_CONFLICT", "TALENT_INVITATION_INVALID_STATE",
"VERIFICATION_ALREADY_APPROVED", "VERIFICATION_ALREADY_PENDING");
private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"ABILITY_RETAKE_LIMIT", "REPORT_RATE_LIMITED", "TALENT_INVITATION_RATE_LIMITED",
"PASSWORD_RATE_LIMITED");
private static final Set<String> CONFLICT_ADDITIONS = Set.of("PASSWORD_STATE_CONFLICT");
private static final Set<String> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID");
private static final Set<String> PASSWORD_BAD_REQUEST = Set.of("PASSWORD_POLICY_INVALID");
private static final Set<String> PHASE_A_CONFLICT = Set.of(
"COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE");
private static final Set<String> PHASE_A_SERVICE_UNAVAILABLE = Set.of(
"COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE");
private static final Set<String> PHASE_B_FORBIDDEN = Set.of("COMPANY_NOT_VERIFIED");
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler =
new GlobalExceptionHandler(new RequestIdAccessor(), registry);
private static final java.util.Map<String, HttpStatus> MODULE_ADDITIONS = java.util.Map.ofEntries(
java.util.Map.entry("ONBOARDING_CHECKLIST_ITEM_NOT_FOUND", HttpStatus.NOT_FOUND),
java.util.Map.entry("SALARY_CITY_NOT_FOUND", HttpStatus.NOT_FOUND),
java.util.Map.entry("SALARY_HISTORY_NOT_FOUND", HttpStatus.NOT_FOUND),
java.util.Map.entry("ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE", HttpStatus.CONFLICT),
java.util.Map.entry("ONBOARDING_ITEM_LIMIT_EXCEEDED", HttpStatus.CONFLICT),
java.util.Map.entry("ONBOARDING_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT),
java.util.Map.entry("ONBOARDING_PROGRESS_CONCURRENT_UPDATE", HttpStatus.CONFLICT),
java.util.Map.entry("ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE", HttpStatus.CONFLICT),
java.util.Map.entry("SALARY_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT),
java.util.Map.entry("SALARY_POLICY_VERSION_CONFLICT", HttpStatus.CONFLICT),
java.util.Map.entry("SALARY_INVALID_PARAMETER", HttpStatus.BAD_REQUEST));
private static HttpStatus baselineStatus(ErrorCode errorCode) {
String name = errorCode.name();
if (MODULE_ADDITIONS.containsKey(name)) return MODULE_ADDITIONS.get(name);
// 新功能追加映射与历史快照分离,不改历史错误码的期望值。
if (name.equals("IDENTITY_SELECTION_REQUIRED")) return HttpStatus.FORBIDDEN;
if (name.equals("IDENTITY_SELECTION_CONFLICT")) return HttpStatus.CONFLICT;
if (name.equals("IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE")) return HttpStatus.SERVICE_UNAVAILABLE;
if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN;
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;
if (CONFLICT_ADDITIONS.contains(name)) return HttpStatus.CONFLICT;
if (PHASE_A_CONFLICT.contains(name)) return HttpStatus.CONFLICT;
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED;
if (PHASE_A_SERVICE_UNAVAILABLE.contains(name)) return HttpStatus.SERVICE_UNAVAILABLE;
if (PHASE_B_FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN;
if (PASSWORD_BAD_REQUEST.contains(name)) return HttpStatus.BAD_REQUEST;
return HttpStatus.BAD_REQUEST;
}
@Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(133);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(132);
for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode);
if (expected == HttpStatus.BAD_REQUEST) {
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(errorCode))
.as("%s 在快照中未显式映射,重构后也不得显式映射", errorCode.name())
.isFalse();
} else {
assertThat(registry.resolve(errorCode))
.as("%s 的显式映射状态", errorCode.name())
.isEqualTo(expected);
}
}
}
@ParameterizedTest(name = "{0}")
@EnumSource(ErrorCode.class)
@DisplayName("B2:处理器实际返回的状态与其 registry 解析结果、以及快照三者一致")
void handlerStatusMatchesRegistryAndSnapshotForEveryErrorCode(ErrorCode errorCode) {
ResponseEntity<ApiResponse<Void>> response =
handler.handleBusinessException(new BusinessException(errorCode), request());
assertThat(response.getStatusCode()).isEqualTo(baselineStatus(errorCode));
assertThat(response.getStatusCode()).isEqualTo(registry.resolve(errorCode));
}
@Test
@DisplayName("B2:FORBIDDEN 保持业务码 2002 与 HTTP 403")
void forbiddenKeepsBusinessCodeAndStatus() {
ResponseEntity<ApiResponse<Void>> response =
handler.handleBusinessException(new BusinessException(ErrorCode.FORBIDDEN), request());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getCode()).isEqualTo(2002);
}
@Test
@DisplayName("B2:UNAUTHORIZED(2001) 保持既有语义(回退 400,不擅自改为 401)")
void unauthorizedKeepsLegacySemantics() {
ResponseEntity<ApiResponse<Void>> response =
handler.handleBusinessException(new BusinessException(ErrorCode.UNAUTHORIZED), request());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getCode()).isEqualTo(2001);
assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("B2:服务不可用类错误码保持当前状态(400)")
void serviceUnavailableKeepsCurrentStatus() {
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("B2:响应体不包含异常类名、堆栈或内部路径")
void responseBodyCarriesNoInternalDetail() {
for (ErrorCode code : new ErrorCode[]{
ErrorCode.FORBIDDEN, ErrorCode.USER_NOT_FOUND, ErrorCode.APPLICATION_DUPLICATE,
ErrorCode.UNAUTHORIZED, ErrorCode.SERVICE_UNAVAILABLE}) {
MockHttpServletRequest request = request();
request.setRequestURI("/admin/scoring/fairness/overview");
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("/admin/scoring/fairness/overview");
assertThat(response.getBody().getRequestId()).isEqualTo("r01b-parity");
}
}
private static MockHttpServletRequest request() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/parity");
request.setAttribute("requestId", "r01b-parity");
return request;
}
}