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 映射**逐项一致**证明。 * *

这里的四个状态桶是**重构前**从 {@code GlobalExceptionHandler.buildBusinessHttpStatuses()} * 逐条抄录的独立快照(亦保存于 {@code docs/acceptance/r01-b/b2-mapping-baseline.json}), * 不是从重构后的实现反推。任何一个错误码的状态发生漂移,本测试都会失败。

*/ class GlobalExceptionHandlerMappingParityTest { private static final Set FORBIDDEN = Set.of( "APPLICATION_OWNER_MISMATCH", "FORBIDDEN", "INTERVIEW_ACCESS_DENIED", "MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER"); private static final Set 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 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 TOO_MANY_REQUESTS = Set.of( "ABILITY_RETAKE_LIMIT", "REPORT_RATE_LIMITED", "TALENT_INVITATION_RATE_LIMITED", "PASSWORD_RATE_LIMITED"); private static final Set CONFLICT_ADDITIONS = Set.of("PASSWORD_STATE_CONFLICT"); private static final Set UNAUTHORIZED = Set.of("CREDENTIALS_INVALID"); private static final Set PASSWORD_BAD_REQUEST = Set.of("PASSWORD_POLICY_INVALID"); private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), registry); private static HttpStatus baselineStatus(ErrorCode errorCode) { String name = errorCode.name(); 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 (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS; if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED; if (PASSWORD_BAD_REQUEST.contains(name)) return HttpStatus.BAD_REQUEST; return HttpStatus.BAD_REQUEST; } @Test @DisplayName("B2:显式映射集合与重构前快照逐项相等") void explicitMappingSetEqualsPreRefactorSnapshot() { assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(115); assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(114); 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> 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> 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> 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> 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; } }