Browse Source

feat: add company registration and onboarding foundation

master
Zhang Shun 1 month ago
parent
commit
8c9429108c
  1. 268
      docs/openapi-rc.json
  2. 106
      docs/phase-a-company-registration-onboarding-implementation-report.md
  3. 157
      docs/phase-a-company-registration-onboarding-independent-verification.md
  4. 33
      docs/verify_openapi.py
  5. 23
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java
  6. 7
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java
  7. 14
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java
  8. 24
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationProperties.java
  9. 43
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java
  10. 11
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationResponse.java
  11. 104
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java
  12. 8
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  13. 7
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  14. 8
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java
  15. 20
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java
  16. 69
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingService.java
  17. 1
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  18. 13
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  19. 2
      mydreamjob-server/src/main/resources/application.yml
  20. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  21. 27
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java
  22. 92
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java
  23. 6
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java
  24. 6
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java
  25. 119
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationConcurrencyMySqlTest.java
  26. 55
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationFeatureFlagTest.java
  27. 133
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationIntegrationTest.java
  28. 76
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationSensitiveDataTest.java
  29. 144
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationServiceTest.java
  30. 145
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationTransactionRollbackTest.java
  31. 6
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java
  32. 13
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java
  33. 10
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java
  34. 29
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java
  35. 3
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java
  36. 46
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java
  37. 84
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingServiceTest.java

268
docs/openapi-rc.json

@ -9426,6 +9426,60 @@
} }
} }
} }
},
"/auth/company/register": {
"post": {
"tags": [
"认证"
],
"summary": "企业注册",
"description": "使用 company-register 短信验证码创建企业账号和企业资料",
"operationId": "companyRegister",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CompanyRegistrationRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCompanyRegistrationResponse"
}
}
}
}
}
}
},
"/companies/me/onboarding": {
"get": {
"tags": [
"企业"
],
"summary": "查询企业入驻状态",
"description": "返回当前企业资料、认证状态和允许动作",
"operationId": "getMyOnboarding",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCompanyOnboardingResponse"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
@ -16056,6 +16110,220 @@
"format": "date-time" "format": "date-time"
} }
} }
},
"CompanyDetails": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 100,
"minLength": 0
},
"logoUrl": {
"type": "string",
"maxLength": 500,
"minLength": 0
},
"description": {
"type": "string"
},
"industry": {
"type": "string",
"maxLength": 80,
"minLength": 0
},
"website": {
"type": "string",
"maxLength": 255,
"minLength": 0
},
"address": {
"type": "string",
"maxLength": 255,
"minLength": 0
}
},
"required": [
"name"
]
},
"CompanyRegistrationRequest": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"minLength": 1,
"pattern": "[0-9]{11}"
},
"code": {
"type": "string",
"minLength": 1,
"pattern": "[0-9]{6}"
},
"password": {
"type": "string",
"maxLength": 64,
"minLength": 12
},
"company": {
"$ref": "#/components/schemas/CompanyDetails"
}
},
"required": [
"code",
"company",
"password",
"phone"
]
},
"ApiResponseCompanyRegistrationResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/CompanyRegistrationResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"CompanyRegistrationResponse": {
"type": "object",
"properties": {
"userId": {
"type": "integer",
"format": "int64"
},
"companyId": {
"type": "integer",
"format": "int64"
},
"role": {
"type": "string"
},
"onboardingStatus": {
"type": "string"
},
"accessToken": {
"type": "string"
},
"refreshToken": {
"type": "string"
},
"expiresIn": {
"type": "integer",
"format": "int64"
}
}
},
"ApiResponseCompanyOnboardingResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/CompanyOnboardingResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"CompanyOnboardingResponse": {
"type": "object",
"properties": {
"company": {
"$ref": "#/components/schemas/CompanySummary"
},
"onboardingStatus": {
"type": "string"
},
"latestVerification": {
"$ref": "#/components/schemas/VerificationSummary"
},
"missingFields": {
"type": "array",
"items": {
"type": "string"
}
},
"allowedActions": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"CompanySummary": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"logoUrl": {
"type": "string"
},
"description": {
"type": "string"
},
"industry": {
"type": "string"
},
"website": {
"type": "string"
},
"address": {
"type": "string"
},
"verified": {
"type": "boolean"
}
}
},
"VerificationSummary": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"status": {
"type": "string"
},
"submittedAt": {
"type": "string",
"format": "date-time"
},
"decidedAt": {
"type": "string",
"format": "date-time"
}
}
} }
}, },
"securitySchemes": { "securitySchemes": {

106
docs/phase-a-company-registration-onboarding-implementation-report.md

@ -0,0 +1,106 @@
# Phase A 企业注册与入驻基础闭环实施报告
日期:2026-09-22
范围:企业专用注册、`company-register` 验证码、事务创建、入驻聚合查询
基线:`master` / `00153aa5d92d13e66d6cdc42ecd1fe903d6e666`
## 1. 结论与边界
本阶段实现了企业注册和企业入驻状态聚合,不进入 Phase B 已认证企业业务门禁,不进入 Phase C 管理员用户与角色管理,不修改 `users.version`、`user_management_actions`、岗位/简历/Offer/面试等业务门禁。
当前未暂存、未提交、未推送;V1–V46 未修改;未新增 V47;没有修改生产数据库的迁移脚本。
## 2. 权威库存
编码前核对了 `AuthController`/`AuthService`、`SmsVerificationService`/`SmsVerificationCodeRepository`、`PasswordPolicy`/`PasswordAuthenticationService`、`AuthSessionService.createSession`、注销手机号墓碑、`User`/`UserRepository`/`user_roles`、`Company`/`CompanyRepository`/`CompanyService`、企业认证服务与状态、`ErrorCode`/`ErrorHttpStatusRegistry`、`SecurityConfig` 以及 OpenAPI 基线。
实际短信用途以源码为准,保留 `login`、`password-set`、`password-reset`,新增并白名单化 `company-register`;任意其他非空用途均拒绝。OpenAPI 基线为 180 paths / 218 operations,本阶段变更为 182 paths / 220 operations。
## 3. 修改文件
生产代码新增:
- `auth/company/CompanyRegistrationProperties.java`
- `auth/company/CompanyRegistrationRequest.java`
- `auth/company/CompanyRegistrationResponse.java`
- `auth/company/CompanyRegistrationService.java`
- `company/CompanyOnboardingResponse.java`
- `company/CompanyOnboardingService.java`
生产代码修改:`AuthController`、`CompanyController`、`SecurityConfig`、`application.yml`、`SmsVerificationService`、`SmsVerificationCodeRepository`、`AuditService`、`ErrorCode`、`ErrorHttpStatusRegistry`。
测试新增/调整为 Phase A 行为锁定、既有短信/映射/OpenAPI 计数同步;未修改 V1–V46 或 `pom.xml`。
## 4. 企业注册契约
新增 `POST /auth/company/register`,使用强类型请求 DTO 和 Jakarta Validation。客户端只能提交手机号、验证码、密码及企业资料,不能提交 `userId`、`role`、`verified` 或 `passwordHash`。成功响应包含 `userId`、`companyId`、`role=COMPANY`、`onboardingStatus=UNVERIFIED`、现有格式的 Access/Refresh Token 和 `expiresIn`,不回显完整手机号、密码、验证码或密码摘要。
新增 `GET /companies/me/onboarding`,仅认证用户可访问,响应只包含企业安全摘要、聚合状态、认证申请安全摘要、`missingFields` 与 `allowedActions`,不返回 `materialsJson`、`decisionReason`、`reviewerId`、存储路径或其他敏感企业数据。未新增重复的 verifications 接口。
## 5. 事务边界
`CompanyRegistrationService.register` 在一个数据库事务中完成:功能开关、短信校验/消费、注销墓碑、手机号占用检查、BCrypt 密码、用户、`user_roles`、企业资料、登录会话和固定脱敏审计事件。用户保存并 flush 后再创建企业和会话;注册失败不会向客户端返回未提交 Token。数据库唯一约束是并发最终防线,`DataIntegrityViolationException` 转为统一冲突业务错误。
审计写入使用 `AuditService.logRequired` 的 `PROPAGATION_MANDATORY` + `saveAndFlush`,因此审计失败会使注册事务回滚。Redis 限流键不属于数据库事务,报告和测试没有虚假声称 Redis 自动回滚。
## 6. 验证码原子消费
`SmsVerificationCodeRepository.consumeIfValid` 使用条件更新,同时约束 `id`、`used=false`、未过期和 `attempts < maxAttempts`。只有受影响行数恰为 1 才算消费成功,0 行统一按验证码无效处理。错误验证码仍递增尝试次数并保留锁定语义;用途在查询前经过固定白名单校验。企业注册事务回滚时,数据库中的 `used` 更新随事务回滚。
修复前行为是“查询 → 修改实体 `used` → `save`”,受控变异到该实现时,`SmsVerificationAtomicConsumptionTest` 为 4 run / 2 failures(条件更新未调用、0 行竞争未失败);恢复条件更新后为 4 / 0 / 0 / 0。该变异证明测试不是空转。
## 7. 入驻状态推导
- `companies.verified=true` → `VERIFIED`;
- 未认证且无申请 → `UNVERIFIED`;
- 最新 `PENDING` 或 `RESUBMITTED` → `PENDING`;
- 最新 `REJECTED` → `REJECTED`;
- 未认证但最新 `APPROVED` 或未知状态 → 失败关闭并返回专用一致性错误。
`missingFields` 从资料实际内容派生,没有新增重复状态字段;`allowedActions` 随聚合状态返回。
## 8. 错误码与安全配置
新增唯一数字码:`COMPANY_REGISTRATION_DISABLED=10301`、`COMPANY_REGISTRATION_CONFLICT=10302`、`COMPANY_ONBOARDING_INCONSISTENT_STATE=10303`、`COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE=10304`。映射统一进入 `ErrorHttpStatusRegistry`:关闭/依赖不可用为 503,冲突/不一致为 409。
匿名放行仅新增 `POST /auth/company/register`;`GET /companies/me/onboarding` 保持认证,学生、管理员和匿名均不获得企业入驻数据。日志、审计和响应不记录密码、摘要、验证码、Token、SQL、手机号或请求 DTO。
## 9. 测试矩阵
新增了企业注册 service/integration/concurrency、入驻 service/controller、验证码原子消费、用途白名单、事务回滚、敏感数据、功能开关等测试类;并同步了既有短信安全、公司 Controller 和异常映射测试。
- Phase A 定向测试:全部通过;两项阻断整改后的完整定向集 **28/28**;
- 真实 MySQL/Testcontainers 并发:1/1,通过同验证码双线程仅一个成功;
- `mvn test`:**2865 / Failures 0 / Errors 0 / Skipped 0**;
- `mvn clean verify`:**2865 / Failures 0 / Errors 0 / Skipped 0**;
- OpenAPI:`python ..\\docs\\verify_openapi.py` → **182 paths / 220 operations,All checks passed**。
全量验证期间出现的 Hikari 重连和 MySQL 8.4/Flyway 兼容性 warning 均未导致失败;一次与编译并发运行的错误结果未计入,随后单独顺序运行得到上述完整通过结果。
测试/脚本自身问题:早期黑盒驱动曾把短信发送参数误放在 JSON body(实际接口是 query 参数),且临时 JWT 密钥长度不足 32 字符;两项均在产品代码之外修正。重复运行时还触发了短信限流,随后销毁临时数据库并以全新隔离容器重跑,得到本节所列成功结果。未放宽产品断言。
## 10. 真实 MySQL/Redis/RabbitMQ 黑盒
使用临时隔离容器(MySQL 8.4、Redis 7.4、RabbitMQ 3.13)和最新 JAR 完成真实 HTTP 验收。三中间件均真实健康,应用 `/api/v1/health` 为 200/UP。
验证结果:匿名 onboarding 为 401;无有效验证码注册被拒;发送 `company-register` 后注册 200;密码登录 200;`/auth/me` 200;带注册 Access Token 的 onboarding 200。数据库逐表核对为:目标手机号 `users=1`、`companies=1`、`user_roles=1` 且角色仅 `COMPANY`、`companies.verified=0`、验证码 `used=1`。密码登录产生第二个合法会话,属于预期行为。临时容器、临时 JAR 进程、临时端口和运行日志均已清理,未触碰既有 backend-infra 卷或历史数据。
## 11. 敏感信息扫描
扫描新增生产代码、测试代码、OpenAPI、验证脚本相关输出和本报告:未发现真实手机号、明文密码、BCrypt 摘要、JWT/Bearer、Refresh Token、数据库/Redis/RabbitMQ 密码、私钥、AWS key 或连接串。黑盒使用的临时凭据只存在于进程环境和临时容器,测试结束后容器和日志已删除;报告不记录其值。
## 12. 数据与兼容性
未新增数据库迁移;V1–V46 逐项保持不变。求职者短信登录、现有密码登录/设置/修改/重置、Token 刷新/登出、管理员初始化、既有企业资料和企业认证接口保持原路径与行为。企业注册后经现有 `/auth/password/login` 成功登录并得到 COMPANY 身份。
## 13. 未进入的范围
本轮没有实现已认证企业业务门禁、管理员用户/角色管理、`users.version`、`user_management_actions`、岗位发布、简历、Offer 或面试门禁改造;没有提交、推送或进入 Phase B/C。
## 14. 当前结束状态
验证完成后未暂存、未提交、未推送;无 Java/Maven 残留进程;8080/8081 空闲;临时测试账号与业务数据在隔离容器销毁时清理;未删除审计历史。工作区仍保留既有历史未跟踪文件和两个 Windows stat 缓存假阳性文件,未覆盖、删除或暂存。
## 15. 结论
实施阶段完成后,独立复验发现功能开关的宽松布尔绑定问题;现已整改为字符串配置并严格使用小写字面量 `true` 判断。事务回滚测试也已补齐 users、user_roles、companies、session、audit 五类独立故障注入,新增回滚覆盖后的完整 Phase A 定向集为 **28/28**,全量回归为 **2865/2865**,均无失败、错误或跳过。

157
docs/phase-a-company-registration-onboarding-independent-verification.md

@ -0,0 +1,157 @@
# Phase A 企业注册与入驻基础闭环独立代码复验报告
日期:2026-09-22
## 1. 复验范围与边界
本次复验针对 Phase A:企业专用注册、`company-register` 验证码用途、验证码原子消费、用户/角色/企业/会话事务、企业入驻聚合查询、测试与 OpenAPI。
未进入 Phase B 已认证企业业务门禁,也未进入 Phase C 管理员用户与角色管理;未修改生产代码、未暂存、未提交、未推送。
## 2. 前置状态
- 分支:`master`
- HEAD:`00153aaf5d92d13e66d6cdc42ecd1fe903d6e666`
- 暂存区:空
- 迁移:V1–V46 未修改,未新增 V47
- `pom.xml`:无修改
- 两个既有 Windows stat 缓存假阳性文件的工作区 blob 与 HEAD 相同,未触碰、未暂存
- 历史未跟踪验收文件和 `backend-infra` 文件均保留
## 3. 独立静态核对
通过项:
1. 注册服务使用事务,按 COMPANY 角色创建用户,并显式写入仅含 COMPANY 的 `user_roles`。
2. 用户、企业保存后 flush,再创建登录会话;企业初始 `verified=false`。
3. 注册审计使用固定脱敏文案,未发现密码、验证码、Token 或摘要输出路径。
4. 验证码消费使用条件更新,条件包含 id、`used=false`、未过期和 attempts 上限,受影响行数必须为 1。
5. 短信用途采用固定白名单,包含 `company-register`,不接受任意非空用途。
6. 匿名规则仅新增 `/auth/company/register`;`/companies/me/onboarding` 仍要求认证。
7. 入驻状态对无记录、PENDING/RESUBMITTED、REJECTED、VERIFIED 和 APPROVED 不一致状态分别处理;不一致状态失败关闭。
8. DTO 未暴露 `userId`、客户端 `role`、`verified`、`passwordHash` 等不应由客户端控制的字段。
9. OpenAPI 中新增注册和入驻查询契约,未改变 V1–V46 或既有业务路径。
## 4. 定向测试与真实黑盒
实施测试与复验前已执行的 Phase A 定向测试为 **23/23,Failures=0,Errors=0,Skipped=0**,其中包含真实 MySQL/Testcontainers 并发注册测试。
此前全量结果:
- `mvn test`:2860/0/0/0
- `mvn clean verify`:2860/0/0/0
- OpenAPI:182 paths / 220 operations,All checks passed
隔离 MySQL 8.4、Redis 7.4、RabbitMQ 3.13 和最新 JAR 的黑盒结果:健康检查通过;发送 `company-register` 验证码后注册成功;密码登录成功;`/auth/me` 返回 COMPANY;入驻查询返回 200;目标数据为 1 个 COMPANY 用户、1 个 COMPANY 角色、1 个企业且 `verified=0`,验证码已消费。临时容器、进程、端口和日志已清理。
## 5. 阻断项:功能开关绑定不满足严格字面量要求
生产文件:`mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationProperties.java`
当前字段为:
```java
private boolean enabled = false;
```
Spring Boot 标准布尔绑定会把 `TRUE`、`True`、`yes`、`1`、`on` 均转换为 `true`。独立临时测试使用 `ApplicationContextRunner` 真实绑定配置,逐一设置:
```text
app.company-registration.enabled=TRUE
app.company-registration.enabled=True
app.company-registration.enabled=yes
app.company-registration.enabled=1
app.company-registration.enabled=on
```
复验结果:五个非字面量值均绑定为 enabled=true,测试失败:
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Expecting empty but was: ["TRUE", "True", "yes", "1", "on"]
```
这与设计要求“只有字面量 `true` 才开启,其他值不得误开启”不一致,因此 Phase A 独立复验判定 **FAIL**。临时测试已删除,未留在工作区。
整改建议:将配置原始值改为 String,并通过唯一方法严格判断:
```java
private String enabled = "false";
public boolean isEnabled() {
return "true".equals(enabled);
}
```
同时增加真实 Spring 属性绑定测试,覆盖未设置、`false`、`true`、`TRUE`、`True`、`yes`、`1`、`on`;只有小写字面量 `true` 应开启。
## 6. 整改前的次要测试覆盖缺口
以下是首次独立复验时发现的证据缺口,不作为已证明的生产缺陷:
1. `CompanyRegistrationTransactionRollbackTest` 目前主要覆盖审计失败,未分别注入并断言 users 保存、user_roles 保存、companies 保存、会话创建和审计写入五类失败;对 user_roles/auth_tokens 的零残留断言也不完整。
2. 注册请求中客户端附带未知的 role/verified/passwordHash 字段的既有测试,可能因验证码未准备而返回 400,不能单独证明未知字段被拒绝;DTO 本身未声明这些字段。
3. tombstone、Redis/数据库/会话依赖异常和每种冲突角色的完整 HTTP 黑盒覆盖仍需单独补齐或明确证据。
## 7. 复验结论
Phase A 的主要注册、验证码原子消费、事务、并发、入驻聚合和真实中间件路径基本可用,但严格功能开关绑定存在阻断缺陷;另有事务回滚测试覆盖不足。
最终判定:**FAIL,暂不能进入独立通过或提交检查点。**
本次独立复验未修改生产代码、未暂存、未提交、未推送,未进入 Phase B/C。
## 8. 阻断整改复验(2026-09-22)
针对第 5 节阻断,已按最小范围修改:
- `CompanyRegistrationProperties.enabled` 改为字符串,默认值为 `"false"`;
- `isEnabled()` 仅返回 `"true".equals(enabled)`;
- `CompanyRegistrationFeatureFlagTest` 增加真实 `ApplicationContextRunner` 绑定测试,覆盖 `TRUE`、`True`、`yes`、`1`、`on`、`false` 和 `true`。
整改后验证:
- 功能开关及关联测试:**9/9,F=0、E=0、S=0**;
- Phase A 定向测试:**24/24,F=0、E=0、S=0**;
- `mvn clean verify`:**2861/2861,F=0、E=0、S=0**;
- OpenAPI:**182 paths / 220 operations,All checks passed**。
独立绑定测试确认只有字面量 `true` 开启,五种非字面量值均保持关闭。未新增迁移,未进入 Phase B/C,未提交或推送。
功能开关阻断已清除;第 6 节的事务回滚覆盖缺口已在第 9 节补齐。
说明:第 7 节“FAIL”是整改前复验结论;当前状态以第 8、9 节整改复验结果为准。
## 9. 事务回滚覆盖整改复验(2026-09-22)
`CompanyRegistrationTransactionRollbackTest` 已补齐五类独立故障注入:
- users 保存失败;
- user_roles 真实插入触发器失败(H2 真实 SQL);
- companies 保存失败;
- 会话创建失败;
- 审计写入失败。
每种场景均验证 users、user_roles、companies、auth_tokens 无残留,验证码 `used=false`,并按精确测试手机号清理。测试结果:**5/5,F=0、E=0、S=0**。
## 10. 阻断修复后的完整回归收口(2026-09-22)
为避免把整改前的历史数字误读为当前结果,最终收口结果单独列示如下:
- 功能开关整改后的独立定向:9/9;
- 五类事务回滚注入:5/5;
- Phase A 完整定向测试:**28/28,F=0、E=0、S=0**;
- `mvn clean verify`:**2865/2865,F=0、E=0、S=0,BUILD SUCCESS**;
- OpenAPI:**182 paths / 220 operations,All checks passed**;
- 真实 MySQL/Testcontainers 并发注册:已执行并通过;
- V1–V46 未修改,未新增 V47;未进入 Phase B/C,仍未暂存、未提交、未推送。
期间 H2 user_roles 触发器用于证明真实 SQL 插入失败,触发日志中的预期 SQL 错误属于测试注入证据,不是生产异常;未修改生产错误映射,也未放宽任何断言。
## 11. 复验重跑收口(2026-09-22)
- 独立静态门禁通过:功能开关严格按小写字面量 `true` 开启;匿名放行仅包含 `/auth/company/register`;注册 DTO 未暴露 `userId`/`role`/`verified`/`passwordHash`;验证码白名单、条件消费和 `attempts < maxAttempts` 均已分别在 Service 与 Repository 位置核对;未发现 V47。
- 首次静态脚本曾把 `attempts < c.maxAttempts` 错查在 `SmsVerificationService.java`,造成脚本假阴性;修正为检查 `SmsVerificationCodeRepository.java` 后门禁通过。该问题仅存在于复验脚本,不是产品缺陷。
- Phase A 定向重跑:**28/28,Failures=0、Errors=0、Skipped=0**;真实 `CompanyRegistrationConcurrencyMySqlTest` 启动 MySQL 8.4 Testcontainers 并实际执行。
- OpenAPI:**182 paths / 220 operations,All checks passed**。
- 收尾:无 Java/Maven 残留进程,8080/8081 无监听,暂存区为空;本轮未修改生产代码、未提交、未推送,未进入 Phase B/C。

33
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 180, f"Expected 180 paths, got {len(d['paths'])}" assert len(d['paths']) == 182, f"Expected 182 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
@ -60,6 +60,9 @@ declared_tags = {tag['name'] for tag in d.get('tags', [])}
assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tags - declared_tags)}' assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tags - declared_tags)}'
required = [ required = [
# Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation)
'/auth/company/register',
'/companies/me/onboarding',
'/review-weight-templates', '/review-weight-templates',
'/profile/me/review-weights', '/profile/me/review-weights',
'/profile/me/privacy', '/profile/me/privacy',
@ -644,9 +647,33 @@ assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me', '/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout', '/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset', '/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
'/auth/company/register',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}' }, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated') print(' S02 refresh/logout and password authentication operations validated')
# ── Phase A:企业注册与入驻聚合 ─────────────────────────────────────
assert set(paths['/auth/company/register']) == {'post'}
_company_register = paths['/auth/company/register']['post']
assert _company_register['requestBody']['content']['application/json']['schema']['$ref'] \
== '#/components/schemas/CompanyRegistrationRequest'
assert _company_register['responses']['200']['content']['*/*']['schema']['$ref'] \
== '#/components/schemas/ApiResponseCompanyRegistrationResponse'
_company_req = resolve_schema(d['components']['schemas']['CompanyRegistrationRequest'])
assert sorted(_company_req['properties']) == ['code', 'company', 'password', 'phone']
for _forbidden in ('userId', 'role', 'verified', 'passwordHash'):
assert _forbidden not in json.dumps(_company_req, ensure_ascii=False)
assert set(paths['/companies/me/onboarding']) == {'get'}
assert paths['/companies/me/onboarding']['get']['responses']['200']['content']['*/*']['schema']['$ref'] \
== '#/components/schemas/ApiResponseCompanyOnboardingResponse'
for _schema_name in ('CompanyOnboardingResponse', 'CompanySummary', 'VerificationSummary'):
assert _schema_name in d['components']['schemas'], f'Missing Phase A schema: {_schema_name}'
_onboarding_text = json.dumps({k: d['components']['schemas'][k] for k in
('CompanyOnboardingResponse', 'CompanySummary', 'VerificationSummary')},
ensure_ascii=False)
for _forbidden in ('materialsJson', 'decisionReason', 'reviewerId', 'storagePath'):
assert _forbidden not in _onboarding_text
print(' Phase A company registration/onboarding paths and redacted schemas validated')
# ── S03:唯一新增的企业简历下载接口 ──────────────────────────────── # ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume' _download_path = '/company/candidates/{applicationId}/resume'
assert _download_path in paths, 'Missing company resume download endpoint' assert _download_path in paths, 'Missing company resume download endpoint'
@ -675,10 +702,10 @@ for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}' assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}'
print(' S03 company resume download contract validated; candidate DTOs remain redacted') print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + password authentication 计数 218 # 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 218, f"Expected 218 operations, got {operations}" assert operations == 220, f"Expected 220 operations, got {operations}"
print(" Offer advance endpoints all present") print(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")

23
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java

@ -3,6 +3,8 @@ package com.mydreamjob.audit;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -45,6 +47,27 @@ public class AuditService {
logWithContext(userId, action, entityType, entityId, details, context, capturedAt); logWithContext(userId, action, entityType, entityId, details, context, capturedAt);
} }
/**
* 必须与调用方业务事务一起成功的审计写入。仅用于需要原子审计的事务,
* 不吞异常;普通历史调用继续使用 log() 的非阻断语义。
*/
@Transactional(propagation = Propagation.MANDATORY)
public void logRequired(Long userId, String action, String entityType, Long entityId,
String details, AuditContext context, LocalDateTime capturedAt) {
AuditLog auditLog = AuditLog.builder()
.userId(userId)
.action(action)
.entityType(entityType)
.entityId(entityId)
.details(sanitize(details))
.ipAddress(context != null ? context.getIpAddress() : null)
.userAgent(context != null ? context.getUserAgent() : null)
.requestId(context != null ? context.getRequestId() : null)
.createdAt(capturedAt)
.build();
auditLogRepository.saveAndFlush(auditLog);
}
private void logWithContext(Long userId, String action, String entityType, Long entityId, private void logWithContext(Long userId, String action, String entityType, Long entityId,
String details, AuditContext ctx, LocalDateTime createdAt) { String details, AuditContext ctx, LocalDateTime createdAt) {
try { try {

7
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java

@ -1,6 +1,7 @@
package com.mydreamjob.auth; package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@ -12,6 +13,12 @@ import java.util.Optional;
@Repository @Repository
public interface SmsVerificationCodeRepository extends JpaRepository<SmsVerificationCode, Long> { public interface SmsVerificationCodeRepository extends JpaRepository<SmsVerificationCode, Long> {
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE SmsVerificationCode c SET c.used = true " +
"WHERE c.id = :id AND c.used = false AND c.expiredAt > :now " +
"AND c.attempts < c.maxAttempts")
int consumeIfValid(@Param("id") Long id, @Param("now") LocalDateTime now);
List<SmsVerificationCode> findByPhoneAndCreatedAtAfter(String phone, LocalDateTime after); List<SmsVerificationCode> findByPhoneAndCreatedAtAfter(String phone, LocalDateTime after);
/** /**

14
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java

@ -15,6 +15,7 @@ import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -51,6 +52,8 @@ public class SmsVerificationService {
private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]{11}"); private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]{11}");
private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}"); private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}");
private static final Set<String> ALLOWED_PURPOSES = Set.of(
"login", "password-set", "password-reset", "company-register");
/** /**
* 发送验证码。 * 发送验证码。
@ -160,9 +163,10 @@ public class SmsVerificationService {
throw new BusinessException(ErrorCode.SMS_CODE_INVALID); throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
} }
// 原子性标记已使用并保存 // 条件更新保证同一验证码并发只能被一个事务消费;更新 0 行表示已被其他请求消费。
smsCode.setUsed(true); if (codeRepository.consumeIfValid(smsCode.getId(), now) != 1) {
codeRepository.save(smsCode); throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
}
return true; return true;
} }
@ -189,9 +193,9 @@ public class SmsVerificationService {
} }
private void validatePurpose(String purpose) { private void validatePurpose(String purpose) {
if (purpose == null || purpose.isBlank()) { if (purpose == null || !ALLOWED_PURPOSES.contains(purpose)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, throw new BusinessException(ErrorCode.BAD_REQUEST,
"purpose is required"); "unsupported sms purpose");
} }
} }
} }

24
mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationProperties.java

@ -0,0 +1,24 @@
package com.mydreamjob.auth.company;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/** Feature switch for the public company registration entry point. */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "app.company-registration")
public class CompanyRegistrationProperties {
private String enabled = "false";
/**
* Enables registration only for the exact lower-case literal "true".
* Spring's default Boolean converter also accepts values such as "yes" and
* "1", which must not enable this safety-sensitive feature switch.
*/
public boolean isEnabled() {
return "true".equals(enabled);
}
}

43
mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java

@ -0,0 +1,43 @@
package com.mydreamjob.auth.company;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class CompanyRegistrationRequest {
@NotBlank
@Pattern(regexp = "[0-9]{11}")
private String phone;
@NotBlank
@Pattern(regexp = "[0-9]{6}")
private String code;
@NotBlank
@Size(min = 12, max = 64)
private String password;
@Valid
@NotNull
private CompanyDetails company;
@Data
public static class CompanyDetails {
@NotBlank
@Size(max = 100)
private String name;
@Size(max = 500)
private String logoUrl;
private String description;
@Size(max = 80)
private String industry;
@Size(max = 255)
private String website;
@Size(max = 255)
private String address;
}
}

11
mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationResponse.java

@ -0,0 +1,11 @@
package com.mydreamjob.auth.company;
public record CompanyRegistrationResponse(
Long userId,
Long companyId,
String role,
String onboardingStatus,
String accessToken,
String refreshToken,
long expiresIn) {
}

104
mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java

@ -0,0 +1,104 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.AuthService;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.deletion.AccountDeletionPhoneHasher;
import com.mydreamjob.deletion.AccountDeletionRepository;
import com.mydreamjob.auth.password.PasswordPolicy;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DataAccessException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class CompanyRegistrationService {
private static final String PURPOSE = "company-register";
private final CompanyRegistrationProperties properties;
private final SmsVerificationService smsVerificationService;
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final PasswordPolicy passwordPolicy;
private final PasswordEncoder passwordEncoder;
private final AccountDeletionRepository accountDeletionRepository;
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher;
private final AuthSessionService authSessionService;
private final AuditService auditService;
private final Clock clock;
@Transactional
public CompanyRegistrationResponse register(CompanyRegistrationRequest request, AuditContext ctx) {
if (!properties.isEnabled()) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED);
}
String phone = request.getPhone().trim();
try {
smsVerificationService.verifyCode(phone, request.getCode(), PURPOSE);
String phoneHash = accountDeletionPhoneHasher.hash(phone);
if (accountDeletionRepository.findByDeletedPhoneHash(phoneHash).isPresent()) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_CONFLICT);
}
passwordPolicy.validateNewPassword(request.getPassword(), phone, null, passwordEncoder);
if (userRepository.findByPhoneForUpdate(phone).isPresent()) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_CONFLICT);
}
User user = User.builder()
.phone(phone)
.nickname("企业_" + phone.substring(7))
.passwordHash(passwordEncoder.encode(passwordPolicy.normalize(request.getPassword())))
.passwordChangedAt(LocalDateTime.ofInstant(clock.instant(), AuthSessionService.BUSINESS_ZONE))
.role(User.Role.COMPANY)
.roles(Set.of(User.Role.COMPANY))
.active(true)
.verified(false)
.build();
user = userRepository.saveAndFlush(user);
CompanyRegistrationRequest.CompanyDetails d = request.getCompany();
Company company = Company.builder()
.ownerUserId(user.getId())
.name(d.getName())
.logoUrl(d.getLogoUrl())
.description(d.getDescription())
.industry(d.getIndustry())
.website(d.getWebsite())
.address(d.getAddress())
.verified(false)
.build();
company = companyRepository.saveAndFlush(company);
Instant now = clock.instant();
AuthSessionService.SessionTokens tokens = authSessionService.createSession(user, now, ctx);
auditService.logRequired(user.getId(), "COMPANY_REGISTERED", "COMPANY", company.getId(),
"{\"result\":\"SUCCESS\"}", ctx,
LocalDateTime.ofInstant(now, AuthSessionService.BUSINESS_ZONE));
return new CompanyRegistrationResponse(user.getId(), company.getId(), User.Role.COMPANY.name(),
"UNVERIFIED", tokens.accessToken(), tokens.refreshToken(), AuthService.EXPIRES_IN_SECONDS);
} catch (DataIntegrityViolationException e) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_CONFLICT);
} catch (DataAccessException e) {
// 数据库、Redis 或会话持久化故障统一失败关闭,不把 SQL/连接信息返回给客户端。
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE);
}
}
}

8
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java

@ -233,7 +233,13 @@ public enum ErrorCode {
TALENT_INVITATION_CONCURRENT_UPDATE(10206, "talent invitation was modified concurrently"), TALENT_INVITATION_CONCURRENT_UPDATE(10206, "talent invitation was modified concurrently"),
TALENT_INVITATION_IDEMPOTENCY_CONFLICT(10207, "talent invitation idempotency conflict"), TALENT_INVITATION_IDEMPOTENCY_CONFLICT(10207, "talent invitation idempotency conflict"),
TALENT_INVITATION_CONTENT_INVALID(10208, "invalid talent invitation content"), TALENT_INVITATION_CONTENT_INVALID(10208, "invalid talent invitation content"),
TALENT_INVITATION_RATE_LIMITED(10209, "talent invitation rate limited"); TALENT_INVITATION_RATE_LIMITED(10209, "talent invitation rate limited"),
// 企业注册与入驻 Phase A 103xx
COMPANY_REGISTRATION_DISABLED(10301, "company registration is disabled"),
COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"),
COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"),
COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable");
private final int code; private final int code;
private final String message; private final String message;

7
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java

@ -221,7 +221,12 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT, ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT,
ErrorCode.TALENT_INVITATION_INVALID_STATE, ErrorCode.TALENT_INVITATION_INVALID_STATE,
ErrorCode.VERIFICATION_ALREADY_APPROVED, ErrorCode.VERIFICATION_ALREADY_APPROVED,
ErrorCode.VERIFICATION_ALREADY_PENDING) ErrorCode.VERIFICATION_ALREADY_PENDING,
ErrorCode.COMPANY_REGISTRATION_CONFLICT,
ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE)
.put(HttpStatus.SERVICE_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DISABLED,
ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE)
.put(HttpStatus.TOO_MANY_REQUESTS, .put(HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.PASSWORD_RATE_LIMITED, ErrorCode.PASSWORD_RATE_LIMITED,
ErrorCode.ABILITY_RETAKE_LIMIT, ErrorCode.ABILITY_RETAKE_LIMIT,

8
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java

@ -29,6 +29,14 @@ public class CompanyController {
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final CompanyService companyService; private final CompanyService companyService;
private final CompanyOnboardingService companyOnboardingService;
@Operation(summary = "查询企业入驻状态", description = "返回当前企业资料、认证状态和允许动作")
@GetMapping("/me/onboarding")
public ResponseEntity<ApiResponse<CompanyOnboardingResponse>> getMyOnboarding(HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(companyOnboardingService.get(userId), requestIdAccessor.get(request)));
}
// ── 创建 ────────────────────────────────────────────────── // ── 创建 ──────────────────────────────────────────────────

20
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java

@ -0,0 +1,20 @@
package com.mydreamjob.company;
import java.time.LocalDateTime;
import java.util.List;
public record CompanyOnboardingResponse(
CompanySummary company,
String onboardingStatus,
VerificationSummary latestVerification,
List<String> missingFields,
List<String> allowedActions) {
public record CompanySummary(Long id, String name, String logoUrl, String description,
String industry, String website, String address, Boolean verified) {
}
public record VerificationSummary(Long id, String status, LocalDateTime submittedAt,
LocalDateTime decidedAt) {
}
}

69
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingService.java

@ -0,0 +1,69 @@
package com.mydreamjob.company;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.verification.CompanyVerification;
import com.mydreamjob.verification.CompanyVerificationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class CompanyOnboardingService {
private final UserRoleGuard userRoleGuard;
private final CompanyRepository companyRepository;
private final CompanyVerificationRepository verificationRepository;
@Transactional(readOnly = true)
public CompanyOnboardingResponse get(Long userId) {
userRoleGuard.requireCompany(userId);
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
CompanyVerification latest = verificationRepository.findTopByCompanyIdOrderByIdDesc(company.getId())
.orElse(null);
String status;
if (Boolean.TRUE.equals(company.getVerified())) {
status = "VERIFIED";
} else if (latest == null) {
status = "UNVERIFIED";
} else if ("PENDING".equals(latest.getStatus()) || "RESUBMITTED".equals(latest.getStatus())) {
status = "PENDING";
} else if ("REJECTED".equals(latest.getStatus())) {
status = "REJECTED";
} else if ("APPROVED".equals(latest.getStatus())) {
throw new BusinessException(ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE);
} else {
throw new BusinessException(ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE);
}
List<String> missing = new ArrayList<>();
if (isBlank(company.getName())) missing.add("name");
if (isBlank(company.getIndustry())) missing.add("industry");
if (isBlank(company.getDescription())) missing.add("description");
if (isBlank(company.getAddress())) missing.add("address");
List<String> actions = switch (status) {
case "UNVERIFIED", "REJECTED" -> List.of("UPDATE_PROFILE", "SUBMIT_VERIFICATION");
case "PENDING" -> List.of("UPDATE_PROFILE", "WAIT_REVIEW");
case "VERIFIED" -> List.of("ENTER_COMPANY_WORKSPACE");
default -> List.of();
};
CompanyOnboardingResponse.VerificationSummary verification = latest == null ? null
: new CompanyOnboardingResponse.VerificationSummary(latest.getId(), latest.getStatus(),
latest.getSubmittedAt(), latest.getDecidedAt());
CompanyOnboardingResponse.CompanySummary summary = new CompanyOnboardingResponse.CompanySummary(
company.getId(), company.getName(), company.getLogoUrl(), company.getDescription(),
company.getIndustry(), company.getWebsite(), company.getAddress(), company.getVerified());
return new CompanyOnboardingResponse(summary, status, verification, missing, actions);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

1
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -79,6 +79,7 @@ public class SecurityConfig {
.requestMatchers("/auth/sms/send").permitAll() .requestMatchers("/auth/sms/send").permitAll()
.requestMatchers("/auth/login").permitAll() .requestMatchers("/auth/login").permitAll()
.requestMatchers("/auth/password/login").permitAll() .requestMatchers("/auth/password/login").permitAll()
.requestMatchers("/auth/company/register").permitAll()
.requestMatchers("/auth/password/reset").permitAll() .requestMatchers("/auth/password/reset").permitAll()
.requestMatchers("/auth/register").permitAll() .requestMatchers("/auth/register").permitAll()
// S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。 // S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。

13
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java

@ -13,6 +13,10 @@ import com.mydreamjob.auth.dto.PasswordOperationResponse;
import com.mydreamjob.auth.dto.PasswordResetRequest; import com.mydreamjob.auth.dto.PasswordResetRequest;
import com.mydreamjob.auth.dto.PasswordSetRequest; import com.mydreamjob.auth.dto.PasswordSetRequest;
import com.mydreamjob.auth.password.PasswordAuthenticationService; import com.mydreamjob.auth.password.PasswordAuthenticationService;
import com.mydreamjob.auth.company.CompanyRegistrationRequest;
import com.mydreamjob.auth.company.CompanyRegistrationResponse;
import com.mydreamjob.auth.company.CompanyRegistrationService;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
@ -50,6 +54,15 @@ public class AuthController {
private final SmsVerificationService smsVerificationService; private final SmsVerificationService smsVerificationService;
private final CurrentUserAccessor currentUserAccessor; private final CurrentUserAccessor currentUserAccessor;
private final PasswordAuthenticationService passwordAuthenticationService; private final PasswordAuthenticationService passwordAuthenticationService;
private final CompanyRegistrationService companyRegistrationService;
@Operation(summary = "企业注册", description = "使用 company-register 短信验证码创建企业账号和企业资料")
@PostMapping("/company/register")
public ResponseEntity<ApiResponse<CompanyRegistrationResponse>> companyRegister(
@Valid @RequestBody CompanyRegistrationRequest body, HttpServletRequest request) {
CompanyRegistrationResponse response = companyRegistrationService.register(body, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
@Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。") @Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。")
@PostMapping("/sms/send") @PostMapping("/sms/send")

2
mydreamjob-server/src/main/resources/application.yml

@ -77,6 +77,8 @@ spring:
# S02:Access 与 Refresh 使用独立签名密钥,另设独立的 Token 摘要密钥。 # S02:Access 与 Refresh 使用独立签名密钥,另设独立的 Token 摘要密钥。
# 三项密钥均无默认值,缺失即启动失败(见 AppJwtProperties 校验)。 # 三项密钥均无默认值,缺失即启动失败(见 AppJwtProperties 校验)。
app: app:
company-registration:
enabled: ${COMPANY_REGISTRATION_ENABLED:false}
jwt: jwt:
# 旧配置迁移项:S02 签发/校验的 Token **不使用**该密钥,不得回退到它。 # 旧配置迁移项:S02 签发/校验的 Token **不使用**该密钥,不得回退到它。
# 仍保留是因为 talentdiscovery.CandidateKeyGenerator 用它派生候选人展示键; # 仍保留是因为 talentdiscovery.CandidateKeyGenerator 用它派生候选人展示键;

4
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -436,8 +436,8 @@ class CompanyResumeDownloadIntegrationTest {
} }
} }
} }
assertThat(pathCount).as("密码登录新增四个路径后应为 180 paths").isEqualTo(180); assertThat(pathCount).as("密码登录与企业注册/入驻新增路径后应为 182 paths").isEqualTo(182);
assertThat(opCount).as("密码登录新增四个 operation 后应为 218 operations").isEqualTo(218); assertThat(opCount).as("密码登录与企业注册/入驻新增 operation 后应为 220 operations").isEqualTo(220);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

27
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java

@ -0,0 +1,27 @@
package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.common.exception.BusinessException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ExtendWith(MockitoExtension.class)
class SmsPurposeAllowlistTest {
@Mock SmsVerificationCodeRepository codes;
@Mock RedisTemplate<String, Object> redis;
@Mock AuditService audit;
@Mock SmsCodeSender sender;
@Test
void rejectsArbitraryPurposeBeforeWritingCode() {
SmsVerificationService service = new SmsVerificationService(codes, redis, audit, sender);
assertThatThrownBy(() -> service.sendCode("19900000112", "arbitrary-purpose", null, null, null))
.isInstanceOf(BusinessException.class);
}
}

92
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java

@ -0,0 +1,92 @@
package com.mydreamjob.auth;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.LocalDateTime;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SmsVerificationAtomicConsumptionTest {
@Mock SmsVerificationCodeRepository codes;
@Mock RedisTemplate<String, Object> redis;
@Mock ValueOperations<String, Object> values;
@Mock AuditService audit;
@Mock SmsCodeSender sender;
@InjectMocks SmsVerificationService service;
@BeforeEach
void noLockout() {
lenient().when(redis.hasKey(anyString())).thenReturn(false);
lenient().when(redis.opsForValue()).thenReturn(values);
}
@Test
void successfulCodeUsesConditionalUpdateAndNeverEntitySave() {
SmsVerificationCode code = code(false, 0, 5);
when(codes.streamLatestUnusedByPhoneAndPurpose("19900000201", "company-register"))
.thenReturn(Stream.of(code));
when(codes.consumeIfValid(eq(7L), any())).thenReturn(1);
code.setId(7L);
assertTrue(service.verifyCode("19900000201", "123456", "company-register"));
verify(codes).consumeIfValid(eq(7L), any());
verify(codes, never()).save(any(SmsVerificationCode.class));
}
@Test
void zeroAffectedRowsIsAConsumedOrInvalidCode() {
SmsVerificationCode code = code(false, 0, 5);
code.setId(8L);
when(codes.streamLatestUnusedByPhoneAndPurpose("19900000202", "company-register"))
.thenReturn(Stream.of(code));
when(codes.consumeIfValid(eq(8L), any())).thenReturn(0);
assertThatThrownBy(() -> service.verifyCode("19900000202", "123456", "company-register"))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt").isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode());
verify(codes, never()).save(any(SmsVerificationCode.class));
}
@Test
void unsupportedPurposeIsRejectedBeforeAnyReadOrUpdate() {
assertThatThrownBy(() -> service.verifyCode("19900000203", "123456", "arbitrary"))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt").isEqualTo(ErrorCode.BAD_REQUEST.getCode());
verifyNoInteractions(codes);
}
@Test
void attemptsAtLimitAreNotConsumedAgain() {
SmsVerificationCode code = code(false, 5, 5);
code.setId(9L);
when(codes.streamLatestUnusedByPhoneAndPurpose("19900000204", "company-register"))
.thenReturn(Stream.of(code));
assertThatThrownBy(() -> service.verifyCode("19900000204", "123456", "company-register"))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt").isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode());
verify(codes, never()).consumeIfValid(anyLong(), any());
}
private SmsVerificationCode code(boolean used, int attempts, int maxAttempts) {
return SmsVerificationCode.builder().id(7L).phone("19900000201").code("123456")
.purpose("company-register").used(used).attempts(attempts).maxAttempts(maxAttempts)
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build();
}
}

6
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java

@ -252,10 +252,14 @@ class SmsVerificationSecurityTest {
when(redisTemplate.hasKey(anyString())).thenReturn(false); when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE))
.thenReturn(Stream.of(stored)); .thenReturn(Stream.of(stored));
when(codeRepository.consumeIfValid(any(), any())).thenAnswer(invocation -> {
stored.setUsed(true);
return 1;
});
assertThat(service.verifyCode(SENSITIVE_PHONE, "654321", PURPOSE)).isTrue(); assertThat(service.verifyCode(SENSITIVE_PHONE, "654321", PURPOSE)).isTrue();
assertThat(stored.getUsed()).isTrue(); assertThat(stored.getUsed()).isTrue();
verify(codeRepository).save(stored); verify(codeRepository).consumeIfValid(any(), any());
// 重放:已使用记录不再被查询命中 // 重放:已使用记录不再被查询命中
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE))

6
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java

@ -74,10 +74,10 @@ class SmsVerificationServiceTest {
when(redisTemplate.hasKey(anyString())).thenReturn(false); when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
.thenReturn(java.util.stream.Stream.of(code)); .thenReturn(java.util.stream.Stream.of(code));
when(codeRepository.consumeIfValid(any(), any())).thenReturn(1);
assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login")); assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login"));
assertTrue(code.getUsed()); verify(codeRepository, times(1)).consumeIfValid(any(), any());
verify(codeRepository, times(1)).save(code);
} }
@Test @Test
@ -87,6 +87,7 @@ class SmsVerificationServiceTest {
// 第一次验证通过后 used=true // 第一次验证通过后 used=true
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
.thenReturn(java.util.stream.Stream.of(code)); .thenReturn(java.util.stream.Stream.of(code));
when(codeRepository.consumeIfValid(any(), any())).thenReturn(1);
// 第一次成功 // 第一次成功
smsVerificationService.verifyCode("13800000001", "123456", "login"); smsVerificationService.verifyCode("13800000001", "123456", "login");
@ -141,6 +142,7 @@ class SmsVerificationServiceTest {
// repository 按 createdAt DESC 返回,第一个是最新的 // repository 按 createdAt DESC 返回,第一个是最新的
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
.thenReturn(java.util.stream.Stream.of(newCode)); .thenReturn(java.util.stream.Stream.of(newCode));
when(codeRepository.consumeIfValid(any(), any())).thenReturn(1);
assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login")); assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login"));
// 旧验证码 999999 不应被验证通过 // 旧验证码 999999 不应被验证通过

119
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationConcurrencyMySqlTest.java

@ -0,0 +1,119 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.auth.*;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@Testcontainers(disabledWithoutDocker = false)
@SpringBootTest
@ActiveProfiles("mysql-it")
class CompanyRegistrationConcurrencyMySqlTest {
private static final String PHONE = "19900000321";
private static final String CODE = "123456";
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("company_registration_concurrency")
.withUsername("company_registration_test")
.withPassword("company-registration-test");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("app.company-registration.enabled", () -> "true");
}
@Autowired CompanyRegistrationService service;
@Autowired SmsVerificationCodeRepository codes;
@Autowired UserRepository users;
@Autowired CompanyRepository companies;
@Autowired JdbcTemplate jdbc;
@MockBean RedisTemplate<String, Object> redis;
@AfterEach
void cleanup() {
jdbc.update("DELETE FROM auth_tokens WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM companies WHERE owner_user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM users WHERE phone = ?", PHONE);
jdbc.update("DELETE FROM sms_verification_codes WHERE phone = ?", PHONE);
}
@Test
void oneCodeCanCreateOnlyOneCompanyIdentityWithoutTimingSleeps() throws Exception {
when(redis.hasKey(anyString())).thenReturn(false);
codes.saveAndFlush(SmsVerificationCode.builder().phone(PHONE).purpose("company-register")
.code(CODE).attempts(0).maxAttempts(5).used(false)
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build());
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<Object>> futures = new ArrayList<>();
for (int i = 0; i < 2; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
assertThat(start.await(10, TimeUnit.SECONDS)).isTrue();
try {
return service.register(request(), AuditContext.builder().build());
} catch (Throwable error) {
return error;
}
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
Object first = futures.get(0).get(30, TimeUnit.SECONDS);
Object second = futures.get(1).get(30, TimeUnit.SECONDS);
pool.shutdownNow();
long successes = List.of(first, second).stream().filter(CompanyRegistrationResponse.class::isInstance).count();
assertThat(successes).isEqualTo(1);
assertThat(users.findAll().stream().filter(u -> PHONE.equals(u.getPhone())).count()).isEqualTo(1);
Long userId = users.findByPhone(PHONE).orElseThrow().getId();
assertThat(jdbc.queryForList("SELECT role FROM user_roles WHERE user_id = ?", String.class, userId))
.containsExactly("COMPANY");
assertThat(companies.findByOwnerUserId(userId)).isPresent();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM auth_tokens WHERE user_id = ?", Long.class, userId))
.isEqualTo(1L);
assertThat(List.of(first, second).stream().filter(Throwable.class::isInstance)
.map(Object::toString).allMatch(text -> !text.contains("SQL") && !text.contains("phone"))).isTrue();
}
private CompanyRegistrationRequest request() {
CompanyRegistrationRequest request = new CompanyRegistrationRequest();
request.setPhone(PHONE); request.setCode(CODE); request.setPassword("Company-strong-password-2026");
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails();
details.setName("并发企业"); details.setIndustry("互联网"); details.setDescription("简介"); details.setAddress("地址");
request.setCompany(details);
return request;
}
}

55
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationFeatureFlagTest.java

@ -0,0 +1,55 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import static org.assertj.core.api.Assertions.assertThat;
class CompanyRegistrationFeatureFlagTest {
@Test
void defaultIsClosed() {
assertThat(new CompanyRegistrationProperties().isEnabled()).isFalse();
}
@Test
void onlyBooleanTrueEnablesTheSwitch() {
CompanyRegistrationProperties properties = new CompanyRegistrationProperties();
properties.setEnabled("true");
assertThat(properties.isEnabled()).isTrue();
properties.setEnabled("false");
assertThat(properties.isEnabled()).isFalse();
}
@Test
void springBindingAcceptsOnlyExactLowercaseTrue() {
ApplicationContextRunner runner = new ApplicationContextRunner()
.withUserConfiguration(BindingConfiguration.class);
for (String value : new String[]{"TRUE", "True", "yes", "1", "on", "false"}) {
runner.withPropertyValues("app.company-registration.enabled=" + value)
.run(context -> assertThat(context.getBean(CompanyRegistrationProperties.class).isEnabled())
.as("feature flag value %s", value)
.isFalse());
}
runner.withPropertyValues("app.company-registration.enabled=true")
.run(context -> assertThat(context.getBean(CompanyRegistrationProperties.class).isEnabled())
.isTrue());
}
@Test
void disabledErrorIsStableAndDoesNotExposeRequestData() {
BusinessException error = new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED);
assertThat(error.getErrorMessage()).isEqualTo("company registration is disabled");
assertThat(error.getMessage()).doesNotContain("phone", "password", "code");
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(CompanyRegistrationProperties.class)
static class BindingConfiguration {
}
}

133
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationIntegrationTest.java

@ -0,0 +1,133 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.auth.AuthTokenRepository;
import com.mydreamjob.auth.SmsVerificationCode;
import com.mydreamjob.auth.SmsVerificationCodeRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties = "app.company-registration.enabled=true")
class CompanyRegistrationIntegrationTest {
private static final String PHONE = "19900000121";
private static final String CODE = "123456";
private static final String PASSWORD = "Company-strong-password-2026";
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired private org.springframework.test.web.servlet.MockMvc mockMvc;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private SmsVerificationCodeRepository codeRepository;
@Autowired private AuthTokenRepository authTokenRepository;
@Autowired private PasswordEncoder passwordEncoder;
@Autowired private JdbcTemplate jdbcTemplate;
@Test
@Transactional
void companyRegisterCreatesCompanyIdentityAndSessionAndOnboardingView() throws Exception {
when(redisTemplate.hasKey(anyString())).thenReturn(false);
codeRepository.saveAndFlush(SmsVerificationCode.builder()
.phone(PHONE).purpose("company-register").code(CODE)
.attempts(0).maxAttempts(5).used(false)
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build());
String body = """
{"phone":"%s","code":"%s","password":"%s","company":
{"name":"示例科技","industry":"互联网","description":"企业简介",
"website":"https://example.com","address":"上海"}}
""".formatted(PHONE, CODE, PASSWORD);
String response = mockMvc.perform(post("/auth/company/register")
.contentType(MediaType.APPLICATION_JSON).content(body)
.header("X-Request-Id", "company-register-it"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.userId").isNumber())
.andExpect(jsonPath("$.data.companyId").isNumber())
.andExpect(jsonPath("$.data.role").value("COMPANY"))
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED"))
.andExpect(jsonPath("$.data.accessToken").isString())
.andExpect(jsonPath("$.data.refreshToken").isString())
.andReturn().getResponse().getContentAsString();
assertThat(response).doesNotContain(PASSWORD).doesNotContain("passwordHash");
User user = userRepository.findByPhone(PHONE).orElseThrow();
assertThat(user.getRole()).isEqualTo(User.Role.COMPANY);
assertThat(user.getRoles()).containsExactly(User.Role.COMPANY);
assertThat(user.getActive()).isTrue();
assertThat(user.getVerified()).isFalse();
assertThat(user.getPasswordChangedAt()).isNotNull();
assertThat(passwordEncoder.matches(PASSWORD, user.getPasswordHash())).isTrue();
Company company = companyRepository.findByOwnerUserId(user.getId()).orElseThrow();
assertThat(company.getVerified()).isFalse();
assertThat(jdbcTemplate.queryForList(
"SELECT role FROM user_roles WHERE user_id = ?", String.class, user.getId()))
.containsExactly("COMPANY");
assertThat(authTokenRepository.findAll().stream()
.filter(token -> user.getId().equals(token.getUserId())).toList()).hasSize(1);
assertThat(codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, "company-register"))
.first().extracting(SmsVerificationCode::getUsed).isEqualTo(true);
String accessToken = com.fasterxml.jackson.databind.json.JsonMapper.builder().build()
.readTree(response).path("data").path("accessToken").asText();
mockMvc.perform(get("/companies/me/onboarding")
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED"))
.andExpect(jsonPath("$.data.company.verified").value(false))
.andExpect(jsonPath("$.data.missingFields").isArray())
.andExpect(jsonPath("$.data.allowedActions[0]").value("UPDATE_PROFILE"));
}
@Test
void invalidRequestShapeIsRejectedBeforePersistence() throws Exception {
mockMvc.perform(post("/auth/company/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"phone":"bad","code":"123","password":"weak","company":null}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void companyRegisterDoesNotAcceptClientRoleOrVerificationFields() throws Exception {
mockMvc.perform(post("/auth/company/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"phone":"19900000122","code":"123456","password":"Company-strong-password-2026",
"role":"ADMIN","verified":true,"passwordHash":"leak",
"company":{"name":"示例科技","industry":"互联网","description":"企业简介","address":"上海"}}
"""))
.andExpect(status().isBadRequest());
}
}

76
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationSensitiveDataTest.java

@ -0,0 +1,76 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.auth.password.PasswordPolicy;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.deletion.AccountDeletionPhoneHasher;
import com.mydreamjob.deletion.AccountDeletionRepository;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class CompanyRegistrationSensitiveDataTest {
@Test
void responseAndAuditContainNoPasswordCodePhoneHashOrToken() {
String phone = "19900000301";
String password = "Company-strong-password-2026";
CompanyRegistrationProperties properties = new CompanyRegistrationProperties();
properties.setEnabled("true");
SmsVerificationService sms = mock(SmsVerificationService.class);
UserRepository users = mock(UserRepository.class);
CompanyRepository companies = mock(CompanyRepository.class);
AccountDeletionRepository deletions = mock(AccountDeletionRepository.class);
AccountDeletionPhoneHasher hasher = mock(AccountDeletionPhoneHasher.class);
AuthSessionService sessions = mock(AuthSessionService.class);
AuditService audit = mock(AuditService.class);
when(hasher.hash(phone)).thenReturn("h");
when(deletions.findByDeletedPhoneHash("h")).thenReturn(Optional.empty());
when(users.findByPhoneForUpdate(phone)).thenReturn(Optional.empty());
when(users.saveAndFlush(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0);
user.setId(301L);
return user;
});
when(companies.saveAndFlush(any(Company.class))).thenAnswer(invocation -> {
Company company = invocation.getArgument(0);
company.setId(401L);
return company;
});
when(sessions.createSession(any(), any(), any()))
.thenReturn(new AuthSessionService.SessionTokens("access-secret", "refresh-secret", "sid"));
CompanyRegistrationService service = new CompanyRegistrationService(properties, sms, users, companies,
new PasswordPolicy(), new BCryptPasswordEncoder(), deletions, hasher, sessions, audit,
Clock.fixed(Instant.parse("2026-09-22T00:00:00Z"), ZoneOffset.UTC));
CompanyRegistrationRequest request = new CompanyRegistrationRequest();
request.setPhone(phone);
request.setCode("123456");
request.setPassword(password);
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails();
details.setName("安全测试企业");
details.setIndustry("互联网");
details.setDescription("简介");
details.setAddress("地址");
request.setCompany(details);
CompanyRegistrationResponse response = service.register(request, AuditContext.builder().build());
assertThat(response.toString()).doesNotContain(phone, password);
verify(audit).logRequired(anyLong(), eq("COMPANY_REGISTERED"), anyString(), anyLong(),
argThat(detailsText -> !detailsText.contains(phone) && !detailsText.contains(password)),
any(), any());
}
}

144
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationServiceTest.java

@ -0,0 +1,144 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.auth.password.PasswordPolicy;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.deletion.AccountDeletionPhoneHasher;
import com.mydreamjob.deletion.AccountDeletionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.dao.DataAccessResourceFailureException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CompanyRegistrationServiceTest {
private static final String PHONE = "19900000111";
@Mock SmsVerificationService sms;
@Mock UserRepository users;
@Mock CompanyRepository companies;
@Mock AccountDeletionRepository deletions;
@Mock AccountDeletionPhoneHasher hasher;
@Mock AuthSessionService sessions;
@Mock AuditService audit;
private final PasswordEncoder encoder = new BCryptPasswordEncoder();
private final PasswordPolicy policy = new PasswordPolicy();
private CompanyRegistrationProperties properties;
private CompanyRegistrationService service;
@BeforeEach
void setUp() {
properties = new CompanyRegistrationProperties();
properties.setEnabled("true");
service = new CompanyRegistrationService(properties, sms, users, companies, policy, encoder,
deletions, hasher, sessions, audit,
Clock.fixed(Instant.parse("2026-09-22T00:00:00Z"), ZoneOffset.UTC));
}
@Test
void createsCompanyUserCompanyAndSession() {
when(hasher.hash(PHONE)).thenReturn("phone-hash");
when(deletions.findByDeletedPhoneHash("phone-hash")).thenReturn(Optional.empty());
when(users.findByPhoneForUpdate(PHONE)).thenReturn(Optional.empty());
when(users.saveAndFlush(any(User.class))).thenAnswer(inv -> {
User u = inv.getArgument(0);
u.setId(11L);
return u;
});
when(companies.saveAndFlush(any(Company.class))).thenAnswer(inv -> {
Company c = inv.getArgument(0);
c.setId(21L);
return c;
});
when(sessions.createSession(any(), any(), any()))
.thenReturn(new AuthSessionService.SessionTokens("access", "refresh", "sid"));
CompanyRegistrationResponse result = service.register(request(), AuditContext.builder().build());
assertThat(result.role()).isEqualTo("COMPANY");
assertThat(result.onboardingStatus()).isEqualTo("UNVERIFIED");
verify(sms).verifyCode(PHONE, "123456", "company-register");
verify(users).saveAndFlush(argThat(u -> u.getRole() == User.Role.COMPANY
&& u.getRoles().contains(User.Role.COMPANY)
&& u.getRoles().size() == 1
&& !u.getVerified()
&& u.getPasswordHash() != null));
verify(companies).saveAndFlush(argThat(c -> c.getOwnerUserId().equals(11L)
&& !c.getVerified()));
verify(audit).logRequired(eq(11L), eq("COMPANY_REGISTERED"), eq("COMPANY"), eq(21L),
anyString(), any(), any());
}
@Test
void disabledRegistrationFailsBeforeSmsConsumption() {
properties.setEnabled("false");
assertThatThrownBy(() -> service.register(request(), AuditContext.builder().build()))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt").isEqualTo(ErrorCode.COMPANY_REGISTRATION_DISABLED.getCode());
verifyNoInteractions(sms, users, companies);
}
@Test
void dependencyFailureIsMappedToStableServiceUnavailableCode() {
when(sms.verifyCode(anyString(), anyString(), eq("company-register")))
.thenThrow(new DataAccessResourceFailureException("connection details must not escape"));
assertThatThrownBy(() -> service.register(request(), AuditContext.builder().build()))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt")
.isEqualTo(ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE.getCode());
verifyNoInteractions(users, companies, sessions, audit);
}
@Test
void allExistingPrincipalRolesUseTheSameConflictResponse() {
when(hasher.hash(PHONE)).thenReturn("phone-hash");
when(deletions.findByDeletedPhoneHash("phone-hash")).thenReturn(Optional.empty());
for (User.Role role : User.Role.values()) {
reset(users);
User existing = User.builder().id(100L).phone(PHONE).role(role).roles(java.util.Set.of(role)).build();
when(users.findByPhoneForUpdate(PHONE)).thenReturn(Optional.of(existing));
assertThatThrownBy(() -> service.register(request(), AuditContext.builder().build()))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt")
.isEqualTo(ErrorCode.COMPANY_REGISTRATION_CONFLICT.getCode());
}
}
private CompanyRegistrationRequest request() {
CompanyRegistrationRequest request = new CompanyRegistrationRequest();
request.setPhone(PHONE);
request.setCode("123456");
request.setPassword("StrongCompanyPassword#1");
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails();
details.setName("示例企业");
details.setIndustry("互联网");
details.setDescription("企业简介");
details.setAddress("上海");
request.setCompany(details);
return request;
}
}

145
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationTransactionRollbackTest.java

@ -0,0 +1,145 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.*;
import com.mydreamjob.auth.password.PasswordPolicy;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.Company;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.h2.api.Trigger;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.doThrow;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties = "app.company-registration.enabled=true")
class CompanyRegistrationTransactionRollbackTest {
private static final String PHONE = "19900000311";
@Autowired CompanyRegistrationService service;
@Autowired SmsVerificationCodeRepository codes;
@Autowired UserRepository users;
@Autowired CompanyRepository companies;
@Autowired AuthTokenRepository tokens;
@Autowired JdbcTemplate jdbc;
@MockBean RedisTemplate<String, Object> redis;
@MockBean AuditService audit;
@SpyBean UserRepository userRepositorySpy;
@SpyBean CompanyRepository companyRepositorySpy;
@SpyBean AuthSessionService authSessionServiceSpy;
@AfterEach
void cleanup() {
jdbc.update("DELETE FROM auth_tokens WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM companies WHERE owner_user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM users WHERE phone = ?", PHONE);
jdbc.update("DELETE FROM sms_verification_codes WHERE phone = ?", PHONE);
}
@Test
void auditFailureRollsBackUserRoleCompanySessionAndCodeConsumption() {
doThrow(new DataAccessResourceFailureException("audit unavailable")).when(audit)
.logRequired(anyLong(), eq("COMPANY_REGISTERED"), anyString(), anyLong(), anyString(), any(), any());
assertRollbackAfterInjectedFailure("audit");
}
@Test
void userSaveFailureRollsBackAllRowsAndCodeConsumption() {
doThrow(new DataAccessResourceFailureException("user store unavailable"))
.when(userRepositorySpy).saveAndFlush(any(User.class));
assertRollbackAfterInjectedFailure("user");
}
@Test
void userRoleFlushFailureRollsBackAllRowsAndCodeConsumption() {
createUserRoleFailureTrigger();
try {
assertRollbackAfterInjectedFailure("user-role");
} finally {
jdbc.execute("DROP TRIGGER IF EXISTS phase_a_fail_user_roles");
}
}
@Test
void companySaveFailureRollsBackAllRowsAndCodeConsumption() {
doThrow(new DataAccessResourceFailureException("company store unavailable"))
.when(companyRepositorySpy).saveAndFlush(any(Company.class));
assertRollbackAfterInjectedFailure("company");
}
@Test
void sessionCreationFailureRollsBackAllRowsAndCodeConsumption() {
doThrow(new DataAccessResourceFailureException("session store unavailable"))
.when(authSessionServiceSpy).createSession(any(User.class), any(Instant.class), any(AuditContext.class));
assertRollbackAfterInjectedFailure("session");
}
private void assertRollbackAfterInjectedFailure(String suffix) {
org.mockito.Mockito.when(redis.hasKey(anyString())).thenReturn(false);
codes.saveAndFlush(SmsVerificationCode.builder().phone(PHONE).purpose("company-register")
.code("123456").attempts(0).maxAttempts(5).used(false)
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build());
CompanyRegistrationRequest request = new CompanyRegistrationRequest();
request.setPhone(PHONE); request.setCode("123456"); request.setPassword("Company-strong-password-2026");
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails();
details.setName("回滚企业-" + suffix); details.setIndustry("互联网"); details.setDescription("简介"); details.setAddress("地址");
request.setCompany(details);
assertThatThrownBy(() -> service.register(request, AuditContext.builder().build()))
.isInstanceOf(com.mydreamjob.common.exception.BusinessException.class);
assertThat(users.findByPhone(PHONE)).isEmpty();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", Long.class, PHONE)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM companies WHERE name = ?", Long.class, "回滚企业-" + suffix)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM auth_tokens WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", Long.class, PHONE)).isZero();
assertThat(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, "company-register"))
.singleElement().extracting(SmsVerificationCode::getUsed).isEqualTo(false);
}
private void createUserRoleFailureTrigger() {
jdbc.execute("CREATE TRIGGER phase_a_fail_user_roles BEFORE INSERT ON user_roles "
+ "FOR EACH ROW CALL '" + FailUserRoleInsertTrigger.class.getName() + "'");
}
/** H2-only failure injector: the production transaction still executes real SQL. */
public static final class FailUserRoleInsertTrigger implements Trigger {
@Override
public void init(Connection conn, String schemaName, String triggerName,
String tableName, boolean before, int type) {
}
@Override
public void fire(Connection conn, Object[] oldRow, Object[] newRow) throws SQLException {
throw new SQLException("user role store unavailable");
}
@Override
public void close() {
}
@Override
public void remove() {
}
}
}

6
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java

@ -64,13 +64,16 @@ class GlobalExceptionHandlerStatusContractTest {
"ASSESSMENT_DUPLICATE_ACTIVE", "ASSESSMENT_IDEMPOTENCY_CONFLICT", "ASSESSMENT_DUPLICATE_ACTIVE", "ASSESSMENT_IDEMPOTENCY_CONFLICT",
"ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", "ABILITY_DUPLICATE_ACTIVE", "ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", "ABILITY_DUPLICATE_ACTIVE",
"ABILITY_IDEMPOTENCY_CONFLICT", "ABILITY_RETAKE_COOLDOWN", "ABILITY_IDEMPOTENCY_CONFLICT", "ABILITY_RETAKE_COOLDOWN",
"RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION"); "RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION",
"COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE");
private static final Set<String> TOO_MANY_REQUESTS = Set.of( private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED", "REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED",
"PASSWORD_RATE_LIMITED"); "PASSWORD_RATE_LIMITED");
private static final Set<String> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID"); private static final Set<String> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID");
private static final Set<String> SERVICE_UNAVAILABLE = Set.of(
"COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE");
private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), new ErrorHttpStatusRegistry()); private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), new ErrorHttpStatusRegistry());
@ -106,6 +109,7 @@ class GlobalExceptionHandlerStatusContractTest {
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS; if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
if ("PASSWORD_STATE_CONFLICT".equals(name)) return HttpStatus.CONFLICT; if ("PASSWORD_STATE_CONFLICT".equals(name)) return HttpStatus.CONFLICT;
if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED; if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED;
if (SERVICE_UNAVAILABLE.contains(name)) return HttpStatus.SERVICE_UNAVAILABLE;
return HttpStatus.BAD_REQUEST; return HttpStatus.BAD_REQUEST;
} }
} }

13
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java

@ -28,15 +28,16 @@ class ErrorHttpStatusRegistryTest {
/** 允许出现在显式映射里的状态集合;密码登录新增 401/400 契约。 */ /** 允许出现在显式映射里的状态集合;密码登录新增 401/400 契约。 */
private static final Set<HttpStatus> ALLOWED_EXPLICIT_STATUSES = Set.of( private static final Set<HttpStatus> ALLOWED_EXPLICIT_STATUSES = Set.of(
HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.CONFLICT, HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.CONFLICT,
HttpStatus.TOO_MANY_REQUESTS, HttpStatus.UNAUTHORIZED, HttpStatus.BAD_REQUEST); HttpStatus.TOO_MANY_REQUESTS, HttpStatus.UNAUTHORIZED, HttpStatus.BAD_REQUEST,
HttpStatus.SERVICE_UNAVAILABLE);
@Test @Test
@DisplayName("密码登录:显式映射包含新增认证状态") @DisplayName("密码登录:显式映射包含新增认证状态")
void explicitMappingCountMatchesBaseline() { void explicitMappingCountMatchesBaseline() {
assertThat(registry.explicitMappingCount()).isEqualTo(115); assertThat(registry.explicitMappingCount()).isEqualTo(119);
// 116 个常量只覆盖 115 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 // 119 个常量只覆盖 118 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
// DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(114); assertThat(registry.explicitNumericCodeCount()).isEqualTo(118);
} }
@Test @Test
@ -121,12 +122,12 @@ class ErrorHttpStatusRegistryTest {
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN))
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThat(view).hasSize(115); assertThat(view).hasSize(119);
Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView(); Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView();
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThat(numeric).hasSize(114); assertThat(numeric).hasSize(118);
} }
@Test @Test

10
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java

@ -86,6 +86,10 @@ class GlobalExceptionHandlerMappingParityTest {
private static final Set<String> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID"); 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> 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 final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler = private final GlobalExceptionHandler handler =
@ -97,8 +101,10 @@ class GlobalExceptionHandlerMappingParityTest {
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;
if (CONFLICT_ADDITIONS.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 (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED; if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED;
if (PHASE_A_SERVICE_UNAVAILABLE.contains(name)) return HttpStatus.SERVICE_UNAVAILABLE;
if (PASSWORD_BAD_REQUEST.contains(name)) return HttpStatus.BAD_REQUEST; if (PASSWORD_BAD_REQUEST.contains(name)) return HttpStatus.BAD_REQUEST;
return HttpStatus.BAD_REQUEST; return HttpStatus.BAD_REQUEST;
} }
@ -106,8 +112,8 @@ class GlobalExceptionHandlerMappingParityTest {
@Test @Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等") @DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() { void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(115); assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(119);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(114); assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(118);
for (ErrorCode errorCode : ErrorCode.values()) { for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode); HttpStatus expected = baselineStatus(errorCode);

29
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java

@ -35,6 +35,11 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
class R01BIndependentHttpMappingParityTest { class R01BIndependentHttpMappingParityTest {
private static final String BASELINE = "/r01b/error-code-http-status-baseline.tsv"; private static final String BASELINE = "/r01b/error-code-http-status-baseline.tsv";
private static final Map<String, HttpStatus> PHASE_A_ADDITIONS = Map.of(
"COMPANY_REGISTRATION_DISABLED", HttpStatus.SERVICE_UNAVAILABLE,
"COMPANY_REGISTRATION_CONFLICT", HttpStatus.CONFLICT,
"COMPANY_ONBOARDING_INCONSISTENT_STATE", HttpStatus.CONFLICT,
"COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE);
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler = private final GlobalExceptionHandler handler =
@ -78,14 +83,20 @@ class R01BIndependentHttpMappingParityTest {
void everyEnumConstantMatchesHeadBaselineByName() { void everyEnumConstantMatchesHeadBaselineByName() {
Map<String, int[]> base = baseline(); Map<String, int[]> base = baseline();
// 169 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 168 条 // HEAD 基线有 169 条;Phase A 追加 3 个错误码,不修改任何基线行。
assertThat(base).hasSize(169); assertThat(base).hasSize(169);
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK");
assertThat(ErrorCode.values()).hasSize(169); assertThat(ErrorCode.values()).hasSize(173);
int checked = 0; int checked = 0;
for (ErrorCode code : ErrorCode.values()) { for (ErrorCode code : ErrorCode.values()) {
int[] expected = base.get(code.name()); int[] expected = base.get(code.name());
if (expected == null) {
assertThat(PHASE_A_ADDITIONS).containsKey(code.name());
assertThat(registry.resolve(code)).isEqualTo(PHASE_A_ADDITIONS.get(code.name()));
checked++;
continue;
}
assertThat(expected).as("基线必须覆盖 %s", code.name()).isNotNull(); assertThat(expected).as("基线必须覆盖 %s", code.name()).isNotNull();
assertThat(code.getCode()).as("%s 的数字码不得改变", code.name()).isEqualTo(expected[0]); assertThat(code.getCode()).as("%s 的数字码不得改变", code.name()).isEqualTo(expected[0]);
assertThat(registry.resolve(code).value()) assertThat(registry.resolve(code).value())
@ -93,7 +104,7 @@ class R01BIndependentHttpMappingParityTest {
.isEqualTo(expected[1]); .isEqualTo(expected[1]);
checked++; checked++;
} }
assertThat(checked).isEqualTo(169); assertThat(checked).isEqualTo(173);
} }
@Test @Test
@ -105,10 +116,10 @@ class R01BIndependentHttpMappingParityTest {
assertThat(explicitInBaseline).isEqualTo(115); assertThat(explicitInBaseline).isEqualTo(115);
assertThat(base.size() - explicitInBaseline).isEqualTo(54); assertThat(base.size() - explicitInBaseline).isEqualTo(54);
assertThat(registry.explicitMappingCount()).isEqualTo(115); assertThat(registry.explicitMappingCount()).isEqualTo(119);
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54); assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54);
// 115 个常量仅覆盖 114 个唯一数字码(9103 共用) // 118 个常量仅覆盖 117 个唯一数字码(9103 共用)
assertThat(registry.explicitNumericCodeCount()).isEqualTo(114); assertThat(registry.explicitNumericCodeCount()).isEqualTo(118);
} }
@Test @Test
@ -118,6 +129,8 @@ class R01BIndependentHttpMappingParityTest {
assertThat(registry.resolve(ErrorCode.CREDENTIALS_INVALID)).isEqualTo(HttpStatus.UNAUTHORIZED); assertThat(registry.resolve(ErrorCode.CREDENTIALS_INVALID)).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN); assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.COMPANY_REGISTRATION_DISABLED))
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).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.USER_NOT_FOUND)).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(registry.resolve(ErrorCode.APPLICATION_DUPLICATE)).isEqualTo(HttpStatus.CONFLICT); assertThat(registry.resolve(ErrorCode.APPLICATION_DUPLICATE)).isEqualTo(HttpStatus.CONFLICT);
@ -218,9 +231,13 @@ class R01BIndependentHttpMappingParityTest {
// 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明 // 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明
Map<String, int[]> base = baseline(); Map<String, int[]> base = baseline();
for (ErrorCode c : ErrorCode.values()) { for (ErrorCode c : ErrorCode.values()) {
if (PHASE_A_ADDITIONS.containsKey(c.name())) {
assertThat(registry.resolve(c)).isEqualTo(PHASE_A_ADDITIONS.get(c.name()));
} else {
assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]); assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]);
} }
} }
}
@Test @Test
@DisplayName("独立复验:未知数字码仍为 400,且处理器响应不含堆栈或内部类名") @DisplayName("独立复验:未知数字码仍为 400,且处理器响应不含堆栈或内部类名")

3
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java

@ -29,6 +29,9 @@ class CompanyControllerMvcTest {
@MockBean @MockBean
private CompanyService companyService; private CompanyService companyService;
@MockBean
private CompanyOnboardingService companyOnboardingService;
@Test @Test
void getCompany_nonNumericId_shouldReturn400WithCode1001() throws Exception { void getCompany_nonNumericId_shouldReturn400WithCode1001() throws Exception {
mockMvc.perform(get("/companies/abc")) mockMvc.perform(get("/companies/abc"))

46
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java

@ -0,0 +1,46 @@
package com.mydreamjob.company;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.security.CurrentUserAccessor;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(CompanyController.class)
@AutoConfigureMockMvc(addFilters = false)
@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, AuthSessionService.class,
java.time.Clock.class, RequestIdAccessor.class, CurrentUserAccessor.class})
class CompanyOnboardingControllerTest {
@Autowired MockMvc mockMvc;
@MockBean CompanyService companyService;
@MockBean CompanyOnboardingService onboardingService;
@MockBean CurrentUserAccessor userAccessor;
@Test
void onboardingEndpointReturnsSafeAggregateAndUsesAuthenticatedUser() throws Exception {
when(userAccessor.requireUserId()).thenReturn(77L);
when(onboardingService.get(77L)).thenReturn(new CompanyOnboardingResponse(
new CompanyOnboardingResponse.CompanySummary(12L, "企业", null, "简介", "互联网", null, "地址", false),
"UNVERIFIED", null, List.of("INDUSTRY"), List.of("UPDATE_PROFILE", "SUBMIT_VERIFICATION")));
mockMvc.perform(get("/companies/me/onboarding"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED"))
.andExpect(jsonPath("$.data.allowedActions[1]").value("SUBMIT_VERIFICATION"))
.andExpect(jsonPath("$.data.company.name").value("企业"));
verify(onboardingService).get(77L);
}
}

84
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingServiceTest.java

@ -0,0 +1,84 @@
package com.mydreamjob.company;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.company.CompanyOnboardingResponse.VerificationSummary;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.verification.CompanyVerification;
import com.mydreamjob.verification.CompanyVerificationRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CompanyOnboardingServiceTest {
@Mock UserRoleGuard guard;
@Mock CompanyRepository companies;
@Mock CompanyVerificationRepository verifications;
@Test
void derivesUnverifiedAndDoesNotExposeReviewDetails() {
Company company = Company.builder().id(7L).ownerUserId(9L).name("企业")
.industry("互联网").verified(false).build();
when(companies.findByOwnerUserId(9L)).thenReturn(Optional.of(company));
when(verifications.findTopByCompanyIdOrderByIdDesc(7L)).thenReturn(Optional.empty());
CompanyOnboardingResponse result = new CompanyOnboardingService(guard, companies, verifications).get(9L);
assertThat(result.onboardingStatus()).isEqualTo("UNVERIFIED");
assertThat(result.latestVerification()).isNull();
assertThat(result.allowedActions()).containsExactly("UPDATE_PROFILE", "SUBMIT_VERIFICATION");
verify(guard).requireCompany(9L);
}
@Test
void derivesPendingWithoutReturningSensitiveMaterial() {
Company company = Company.builder().id(7L).ownerUserId(9L).name("企业")
.industry("互联网").description("简介").address("地址").verified(false).build();
CompanyVerification verification = CompanyVerification.builder().id(8L).companyId(7L)
.status("RESUBMITTED").materialsJson("secret-material").decisionReason("internal").build();
when(companies.findByOwnerUserId(9L)).thenReturn(Optional.of(company));
when(verifications.findTopByCompanyIdOrderByIdDesc(7L)).thenReturn(Optional.of(verification));
VerificationSummary result = new CompanyOnboardingService(guard, companies, verifications).get(9L)
.latestVerification();
assertThat(result.status()).isEqualTo("RESUBMITTED");
assertThat(result).hasNoNullFieldsOrPropertiesExcept("submittedAt", "decidedAt");
verifyNoMoreInteractions(companies, verifications);
}
@Test
void verifiedCompanyWinsOverPendingReview() {
Company company = Company.builder().id(17L).ownerUserId(19L).name("已认证企业")
.verified(true).build();
when(companies.findByOwnerUserId(19L)).thenReturn(Optional.of(company));
when(verifications.findTopByCompanyIdOrderByIdDesc(17L)).thenReturn(Optional.of(
CompanyVerification.builder().id(18L).companyId(17L).status("PENDING").build()));
CompanyOnboardingResponse result = new CompanyOnboardingService(guard, companies, verifications).get(19L);
assertThat(result.onboardingStatus()).isEqualTo("VERIFIED");
assertThat(result.allowedActions()).containsExactly("ENTER_COMPANY_WORKSPACE");
}
@Test
void approvedReviewWithoutCompanyVerificationFailsClosed() {
Company company = Company.builder().id(27L).ownerUserId(29L).name("不一致企业")
.verified(false).build();
when(companies.findByOwnerUserId(29L)).thenReturn(Optional.of(company));
when(verifications.findTopByCompanyIdOrderByIdDesc(27L)).thenReturn(Optional.of(
CompanyVerification.builder().id(28L).companyId(27L).status("APPROVED").build()));
assertThatThrownBy(() -> new CompanyOnboardingService(guard, companies, verifications).get(29L))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt")
.isEqualTo(com.mydreamjob.common.exception.ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE.getCode());
}
}
Loading…
Cancel
Save