package com.mydreamjob.common.exception; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.logging.RequestIdAccessor; 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.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; /** Locks the pre-R01-B public HTTP contract for every existing business error code. */ class GlobalExceptionHandlerStatusContractTest { private static final Set FORBIDDEN = Set.of( "FORBIDDEN", "INTERVIEW_ACCESS_DENIED", "APPLICATION_OWNER_MISMATCH", "MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER", "COMPANY_NOT_VERIFIED", "IDENTITY_SELECTION_REQUIRED"); private static final Set NOT_FOUND = Set.of( "COMPANY_NOT_FOUND", "PROFILE_NOT_FOUND", "JOB_NOT_FOUND", "APPLICATION_NOT_FOUND", "OFFER_NOT_FOUND", "OFFER_COUNTER_OFFER_NOT_FOUND", "RESUME_VERSION_NOT_FOUND", "RESUME_EXPORT_TASK_NOT_FOUND", "USER_NOT_FOUND", "REVIEW_NOT_FOUND", "FEEDBACK_NOT_FOUND", "CONVERSATION_NOT_FOUND", "MESSAGE_NOT_FOUND", "APPEAL_NOT_FOUND", "DATA_EXPORT_TASK_NOT_FOUND", "REVIEW_REPLY_NOT_FOUND", "TALENT_INVITATION_NOT_FOUND", "VERIFICATION_NOT_FOUND", "INTERVIEW_SCORE_NOT_FOUND", "REPORT_NOT_FOUND", "ASSESSMENT_TYPE_NOT_FOUND", "ASSESSMENT_ATTEMPT_NOT_FOUND", "ABILITY_TYPE_NOT_FOUND", "ABILITY_ATTEMPT_NOT_FOUND", "ABILITY_RESULT_NOT_FOUND", "RISK_RULE_NOT_FOUND", "RISK_EVENT_NOT_FOUND"); private static final Set CONFLICT = Set.of( "COMPANY_ALREADY_EXISTS", "APPLICATION_DUPLICATE", "INVALID_APPLICATION_STATUS_TRANSITION", "INVALID_JOB_STATUS_TRANSITION", "DUPLICATE_REVIEW", "DUPLICATE_FEEDBACK", "DUPLICATE_OFFER", "INVALID_OFFER_STATUS_TRANSITION", "OFFER_EXPIRED", "OFFER_NOT_EDITABLE", "OFFER_REMINDER_DUPLICATE", "OFFER_COUNTER_OFFER_EXISTS", "OFFER_COUNTER_OFFER_NOT_PENDING", "OFFER_COUNTER_OFFER_EXPIRED", "DEFAULT_RESUME_DELETE_NOT_ALLOWED", "RESUME_EXPORT_IN_PROGRESS", "DUPLICATE_DEFAULT_RESUME", "RESUME_EXPORT_NOT_READY", "MATCH_CLOSED", "MATCH_SCORES_LOCKED", "APPLICATION_NOT_MATCHED", "REVIEW_ALREADY_DECIDED", "INVALID_INTERVIEW_STATUS_TRANSITION", "INTERVIEW_COMPLETE_NOT_READY", "INTERVIEW_ALREADY_RESPONDED", "REVIEW_REPLY_ALREADY_EXISTS", "REVIEW_REPLY_INVALID_STATE", "REVIEW_REPLY_CONCURRENT_UPDATE", "REVIEW_REPLY_IDEMPOTENCY_CONFLICT", "MESSAGE_RECALL_WINDOW_EXPIRED", "MESSAGE_RECALL_CONCURRENT_UPDATE", "TALENT_INVITATION_CANDIDATE_UNAVAILABLE", "TALENT_INVITATION_ALREADY_ACTIVE", "TALENT_INVITATION_INVALID_STATE", "TALENT_INVITATION_EXPIRED", "TALENT_INVITATION_CONCURRENT_UPDATE", "TALENT_INVITATION_IDEMPOTENCY_CONFLICT", "VERIFICATION_ALREADY_PENDING", "VERIFICATION_ALREADY_APPROVED", "INVALID_VERIFICATION_STATUS_TRANSITION", "REVIEW_WEIGHT_CONCURRENT_UPDATE", "PRIVACY_SETTINGS_CONCURRENT_UPDATE", "INVALID_DELETION_STATE", "ACCOUNT_DELETION_PENDING", "ACCOUNT_DELETED", "ACCOUNT_DELETION_CONCURRENT_UPDATE", "ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED", "LAST_ADMIN_CANNOT_DELETE", "APPEAL_DUPLICATE", "APPEAL_ALREADY_DECIDED", "APPEAL_ROLLBACK_LIMIT_EXCEEDED", "APPEAL_INVALID_TRANSITION", "APPEAL_IDEMPOTENCY_CONFLICT", "DATA_EXPORT_DUPLICATE", "DATA_EXPORT_NOT_READY", "DATA_EXPORT_EXPIRED", "DATA_EXPORT_FAILED", "DATA_EXPORT_FILE_NOT_FOUND", "INTERVIEW_SCORE_CONCURRENT_UPDATE", "INTERVIEW_NOT_COMPLETED", "INTERVIEW_SCORE_SUBMITTED", "REPORT_DUPLICATE", "REPORT_ALREADY_DECIDED", "REPORT_INVALID_TRANSITION", "REPORT_IDEMPOTENCY_CONFLICT", "ASSESSMENT_ATTEMPT_INVALID_STATE", "ASSESSMENT_CONCURRENT_UPDATE", "ASSESSMENT_DUPLICATE_ACTIVE", "ASSESSMENT_IDEMPOTENCY_CONFLICT", "ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", "ABILITY_DUPLICATE_ACTIVE", "ABILITY_IDEMPOTENCY_CONFLICT", "ABILITY_RETAKE_COOLDOWN", "RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION", "COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE", "IDENTITY_SELECTION_CONFLICT"); private static final Set TOO_MANY_REQUESTS = Set.of( "REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED", "PASSWORD_RATE_LIMITED"); private static final Set UNAUTHORIZED = Set.of("CREDENTIALS_INVALID"); private static final Set SERVICE_UNAVAILABLE = Set.of( "COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE", "IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE"); private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), new ErrorHttpStatusRegistry()); @ParameterizedTest(name = "{0}") @EnumSource(ErrorCode.class) void everyExistingErrorCodeKeepsItsHttpStatusAndResponseFields(ErrorCode errorCode) { MockHttpServletRequest request = new MockHttpServletRequest(); request.setRequestURI("/api/v1/contract"); request.setAttribute("requestId", "r01b-contract"); ResponseEntity> response = handler.handleBusinessException(new BusinessException(errorCode), request); assertEquals(expectedStatus(errorCode), response.getStatusCode(), errorCode.name()); assertNotNull(response.getBody()); assertEquals(errorCode.getCode(), response.getBody().getCode()); assertEquals(errorCode.getMessage(), response.getBody().getMessage()); assertEquals("r01b-contract", response.getBody().getRequestId()); } @Test void unknownBusinessCodeKeepsBadRequestFallback() { // R01-B/B2:解析入口由 GlobalExceptionHandler 的私有方法收束到 ErrorHttpStatusRegistry, // 断言目标(未知码仍回退 400)保持不变,不再依赖反射。 assertEquals(HttpStatus.BAD_REQUEST, new ErrorHttpStatusRegistry().resolve(123456789)); } private static final java.util.Map 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("RESUME_MESSAGE_UNAVAILABLE", HttpStatus.CONFLICT), java.util.Map.entry("SALARY_INVALID_PARAMETER", HttpStatus.BAD_REQUEST)); private HttpStatus expectedStatus(ErrorCode errorCode) { String name = errorCode.name(); if (MODULE_ADDITIONS.containsKey(name)) return MODULE_ADDITIONS.get(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 (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS; if ("PASSWORD_STATE_CONFLICT".equals(name)) return HttpStatus.CONFLICT; if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED; if (SERVICE_UNAVAILABLE.contains(name)) return HttpStatus.SERVICE_UNAVAILABLE; return HttpStatus.BAD_REQUEST; } }