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
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;
|
|
}
|
|
}
|
|
|