轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

633 lines
29 KiB

package com.mydreamjob.application;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.auth.support.TestAuthTokens;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.job.JobStatus;
import com.mydreamjob.privacy.ResumeVisibility;
import com.mydreamjob.privacy.StudentPrivacySettings;
import com.mydreamjob.privacy.StudentPrivacySettingsRepository;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import com.mydreamjob.profile.storage.ResumeStorageService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* S03-B 企业简历下载权限矩阵(设计 §4.3),真实 SecurityFilterChain + 真实 H2 + 真实本地存储。
*
* <p>只用一个计数包装器包住存储实现,用于证明**拒绝路径零文件读取**;
* 授权链本身完全真实,未被 Mock。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties = "app.resume.storage-dir=target/s03-test-resumes")
class CompanyResumeDownloadIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(8800);
private static final List<String> ALL_STATUSES =
List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final List<String> EFFECTIVE = List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED");
/** 统计 read() 调用次数,用于断言拒绝路径不触碰文件。 */
static final AtomicInteger READ_CALLS = new AtomicInteger();
@TestConfiguration
static class CountingStorageConfig {
@Bean
@Primary
ResumeStorageService countingResumeStorageService(
com.mydreamjob.profile.storage.LocalResumeStorageService delegate) {
return new ResumeStorageService() {
@Override
public String store(Long userId, org.springframework.web.multipart.MultipartFile file) {
return delegate.store(userId, file);
}
@Override
public byte[] read(String storagePath) {
READ_CALLS.incrementAndGet();
return delegate.read(storagePath);
}
@Override
public void delete(String storagePath) {
delegate.delete(storagePath);
}
};
}
}
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired private MockMvc mockMvc;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private JobRepository jobRepository;
@Autowired private ApplicationRepository applicationRepository;
@Autowired private StudentProfileRepository profileRepository;
@Autowired private StudentPrivacySettingsRepository privacyRepository;
@Autowired private ResumeStorageService resumeStorageService;
@Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
private Long companyUserId;
private Long otherCompanyUserId;
private Long companyId;
private Long otherCompanyId;
private Long studentUserId;
private Long adminUserId;
private Long jobId;
private Long applicationId;
private String storagePath;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
READ_CALLS.set(0);
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(anyString())).thenReturn(false);
companyUserId = createUser(User.Role.COMPANY, "S03 企业");
otherCompanyUserId = createUser(User.Role.COMPANY, "S03 他企");
studentUserId = createUser(User.Role.STUDENT, "S03 学生");
adminUserId = createUser(User.Role.ADMIN, "S03 管理员");
companyId = companyRepository.save(Company.builder()
.ownerUserId(companyUserId).name("S03 甲企业").verified(false).build()).getId();
otherCompanyId = companyRepository.save(Company.builder()
.ownerUserId(otherCompanyUserId).name("S03 乙企业").verified(true).build()).getId();
jobId = jobRepository.save(Job.builder()
.companyId(companyId).title("S03 岗位").description("d").city("杭州")
.employmentType("FULL_TIME").status(JobStatus.PUBLISHED.name()).build()).getId();
applicationId = applicationRepository.save(Application.builder()
.jobId(jobId).studentUserId(studentUserId).status("SUBMITTED").build()).getId();
// 真实写入一个 PDF 文件并挂到学生档案
MockMultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf",
"%PDF-1.4 s03 content".getBytes(StandardCharsets.UTF_8));
storagePath = resumeStorageService.store(studentUserId, file);
profileRepository.save(StudentProfile.builder()
.userId(studentUserId)
.resumeUrl(storagePath)
.resumeOriginalName("我的简历.pdf")
.resumeContentType("application/pdf")
.resumeSize(file.getSize())
.build());
}
@AfterEach
void tearDown() {
if (storagePath != null) {
resumeStorageService.delete(storagePath);
}
profileRepository.findByUserId(studentUserId).ifPresent(profileRepository::delete);
privacyRepository.findByStudentUserId(studentUserId).ifPresent(privacyRepository::delete);
if (applicationId != null) applicationRepository.deleteById(applicationId);
if (jobId != null) jobRepository.deleteById(jobId);
if (companyId != null) companyRepository.deleteById(companyId);
if (otherCompanyId != null) companyRepository.deleteById(otherCompanyId);
List.of(companyUserId, otherCompanyUserId, studentUserId, adminUserId)
.forEach(id -> { if (id != null) userRepository.deleteById(id); });
}
private Long createUser(User.Role role, String nickname) {
return userRepository.save(User.builder()
.phone("17" + String.format("%09d", PHONE_SEQ.incrementAndGet()))
.nickname(nickname)
.role(role)
.roles(Set.of(role))
.active(true)
.verified(true)
.build()).getId();
}
private void setVisibility(ResumeVisibility visibility) {
privacyRepository.findByStudentUserId(studentUserId)
.ifPresent(privacyRepository::delete);
if (visibility != null) {
privacyRepository.saveAndFlush(StudentPrivacySettings.builder()
.studentUserId(studentUserId).resumeVisibility(visibility)
.contactVisible(false).version(0L).build());
}
}
private void setStatus(String status) {
Application app = applicationRepository.findById(applicationId).orElseThrow();
app.setStatus(status);
applicationRepository.saveAndFlush(app);
}
private void certifyCompanyForResumePolicyTest() {
Company company = companyRepository.findById(companyId).orElseThrow();
company.setVerified(true);
companyRepository.saveAndFlush(company);
}
private String url() {
return "/company/candidates/" + applicationId + "/resume";
}
private String companyToken() {
return TestAuthTokens.bearer(companyUserId, "COMPANY");
}
// ── 决策矩阵:3 种可见性 × 6 种状态 ─────────────────────────
@Test
void privateIsDeniedForEveryStatusAndNeverReadsFile() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.PRIVATE);
for (String status : ALL_STATUSES) {
setStatus(status);
READ_CALLS.set(0);
int code = perform(companyToken());
assertThat(code).as("PRIVATE 必须 403:" + status).isEqualTo(403);
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + status).isZero();
}
}
@Test
void missingSettingsIsDeniedAsPrivate() {
certifyCompanyForResumePolicyTest();
setVisibility(null);
for (String status : ALL_STATUSES) {
setStatus(status);
READ_CALLS.set(0);
assertThat(perform(companyToken())).as("无设置按 PRIVATE 拒绝:" + status).isEqualTo(403);
assertThat(READ_CALLS.get()).isZero();
}
}
@Test
void appliedCompaniesAllowsOnlyEffectiveStatusesForOwningCompany() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String status : ALL_STATUSES) {
setStatus(status);
READ_CALLS.set(0);
if (EFFECTIVE.contains(status)) {
mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(content().contentType("application/pdf"));
assertThat(READ_CALLS.get()).as("授权通过应读取一次:" + status).isEqualTo(1);
} else {
assertThat(perform(companyToken())).as("WITHDRAWN/REJECTED 应 403:" + status).isEqualTo(403);
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + status).isZero();
}
}
}
@Test
void verifiedCompaniesAllowsAnyStatusOnlyForVerifiedOwningCompany() {
// 甲企业未认证 → 全部拒绝
setVisibility(ResumeVisibility.VERIFIED_COMPANIES);
for (String status : ALL_STATUSES) {
setStatus(status);
READ_CALLS.set(0);
assertThat(perform(companyToken())).as("未认证企业应 403:" + status).isEqualTo(403);
assertThat(READ_CALLS.get()).isZero();
}
// 认证后 → 全部状态允许
Company company = companyRepository.findById(companyId).orElseThrow();
company.setVerified(true);
companyRepository.saveAndFlush(company);
for (String status : ALL_STATUSES) {
setStatus(status);
READ_CALLS.set(0);
assertThat(perform(companyToken())).as("已认证且拥有岗位应 200:" + status).isEqualTo(200);
assertThat(READ_CALLS.get()).isEqualTo(1);
}
}
// ── APPLIED_COMPANIES 白名单:退化状态必须 403 且零读取 ──────
@Test
void appliedCompaniesUnknownStatusReturns403WithoutReadingStorage() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "submitted"}) {
setStatus(unknown);
READ_CALLS.set(0);
var result = mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andReturn().getResponse();
assertThat(result.getStatus()).as("未知/变体状态必须 403:" + unknown).isEqualTo(403);
assertThat(result.getContentAsString()).as("业务码必须 2002:" + unknown).contains("\"code\":2002");
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + unknown).isZero();
}
}
@Test
void appliedCompaniesBlankStatusReturns403WithoutReadingStorage() throws Exception {
// 说明:applications.status 在 V2 中是 NOT NULL,因此 DB 层无法保存 NULL;
// NULL 情形由 CompanyResumeAccessPolicyTest 在内存中覆盖,这里用可达的空白值验证。
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String blank : new String[]{"", " "}) {
setStatus(blank);
READ_CALLS.set(0);
var result = mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andReturn().getResponse();
assertThat(result.getStatus()).as("空白状态必须 403").isEqualTo(403);
assertThat(result.getContentAsString()).as("业务码必须 2002").contains("\"code\":2002");
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件").isZero();
}
}
// ── 跨企业 ─────────────────────────────────────────────────
@Test
void otherCompanyIsAlwaysDenied() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
READ_CALLS.set(0);
assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY")))
.as("跨企业必须 403").isEqualTo(403);
assertThat(READ_CALLS.get()).isZero();
setVisibility(ResumeVisibility.VERIFIED_COMPANIES);
READ_CALLS.set(0);
assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY")))
.as("其他企业即使已认证也必须 403").isEqualTo(403);
assertThat(READ_CALLS.get()).isZero();
}
// ── 角色与非认证 ───────────────────────────────────────────
@Test
void studentAdminAndAnonymousAreDenied() {
READ_CALLS.set(0);
setVisibility(ResumeVisibility.VERIFIED_COMPANIES);
Company company = companyRepository.findById(companyId).orElseThrow();
company.setVerified(true);
companyRepository.saveAndFlush(company);
assertThat(perform(TestAuthTokens.bearer(studentUserId, "STUDENT"))).as("STUDENT 应 403").isEqualTo(403);
assertThat(perform(TestAuthTokens.bearer(adminUserId, "ADMIN"))).as("ADMIN 应 403").isEqualTo(403);
assertThat(performNoAuth()).as("匿名应 401").isEqualTo(401);
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件").isZero();
}
@Test
void nonexistentApplicationIsNotFound() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
READ_CALLS.set(0);
String token = companyToken();
int code;
try {
code = mockMvc.perform(get("/company/candidates/99999999/resume").header("Authorization", token))
.andReturn().getResponse().getStatus();
} catch (Exception e) {
code = -1;
}
assertThat(code).as("不存在的投递应 404").isEqualTo(404);
assertThat(READ_CALLS.get()).isZero();
}
@Test
void missingResumeIsProfileNotFound() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
StudentProfile profile = profileRepository.findByUserId(studentUserId).orElseThrow();
profile.setResumeUrl(null);
profileRepository.saveAndFlush(profile);
assertThat(perform(companyToken())).as("授权通过但无简历应 404").isEqualTo(404);
}
// ── 响应脱敏 ───────────────────────────────────────────────
@Test
void responseDoesNotLeakPathOrIdentity() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
String body = mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(body).as("不得回显存储路径").doesNotContain(storagePath);
assertThat(body).doesNotContain("resumeUrl");
assertThat(body).doesNotContain(String.valueOf(studentUserId));
assertThat(body).doesNotContain("studentUserId");
}
@Test
void contentDispositionUsesSanitizedOriginalName() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("attachment")))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("../"))))
.andExpect(header().string("Cache-Control", "no-store"));
}
// ── 企业候选人列表/详情仍不含 resumeUrl ─────────────────────
@Test
void companyCandidateEndpointsStillExcludeResumeUrl() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
String token = companyToken();
String detail = mockMvc.perform(get("/company/candidates/" + applicationId)
.header("Authorization", token))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(detail).doesNotContain("resumeUrl");
String list = mockMvc.perform(get("/company/candidates").header("Authorization", token))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(list).doesNotContain("resumeUrl");
}
// ── 运行时 OpenAPI 契约(S03 只新增一个企业下载 operation)──
@Test
void openApiContractForCompanyResumeDownload() throws Exception {
String json = mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
var root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
var paths = root.path("paths");
var schemas = root.path("components").path("schemas");
long pathCount = paths.size();
long opCount = 0;
long modulePaths = 0;
long moduleOperations = 0;
var it = paths.fieldNames();
while (it.hasNext()) {
String path = it.next();
boolean module = path.startsWith("/skills/") || path.startsWith("/onboarding/")
|| path.startsWith("/salary-calculator/");
if (module) modulePaths++;
var ops = paths.path(path);
var f = ops.fieldNames();
while (f.hasNext()) {
if (java.util.Set.of("get", "post", "put", "delete", "patch")
.contains(f.next())) {
opCount++;
if (module) moduleOperations++;
}
}
}
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("188 paths 包含首次密码设置、聊天补拉及简历消息").isEqualTo(188);
assertThat(opCount - moduleOperations).as("227 operations 包含首次密码设置、聊天补拉及简历消息").isEqualTo(227);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();
assertThat(download.has("requestBody")).as("下载接口不得有 requestBody").isFalse();
// 不得存在按 studentUserId / candidateKey 下载简历的入口
var names = new java.util.ArrayList<String>();
paths.fieldNames().forEachRemaining(names::add);
for (String p : names) {
String lower = p.toLowerCase();
if (lower.contains("resume") || lower.contains("download")) {
assertThat(lower).as("不得按 studentUserId 下载:" + p).doesNotContain("studentuserid");
assertThat(lower).as("不得按 candidateKey 下载:" + p).doesNotContain("candidatekey");
}
}
// 企业候选人 DTO 不得含 resumeUrl / studentUserId / phone / email / storagePath
for (String dto : new String[]{"CandidateDetailView", "CandidateItemView"}) {
var node = schemas.path(dto);
if (node.isMissingNode()) {
continue;
}
String text = node.toString();
for (String forbidden : new String[]{"resumeUrl", "studentUserId", "phone", "email", "storagePath"}) {
assertThat(text).as(dto + " 不得包含 " + forbidden).doesNotContain(forbidden);
}
}
// Actuator 不得进入业务 OpenAPI
for (String p : names) {
assertThat(p).as("Actuator 不得出现在业务契约").doesNotStartWith("/actuator");
}
// 开发辅助:-Ds03.openapi.snapshot=<目录> 时导出快照
String snapshotDir = System.getProperty("s03.openapi.snapshot");
if (snapshotDir != null && !snapshotDir.isBlank()) {
java.nio.file.Files.write(
java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"),
json.getBytes(StandardCharsets.UTF_8));
}
}
// ── 副作用:企业下载只读 ───────────────────────────────────
/**
* 表名**固定白名单**(不做任意外部表名拼接)+ 每张表明确选择的稳定业务字段。
* 字段名以真实表结构为准。
*/
private static java.util.Map<String, String> digestColumns() {
java.util.Map<String, String> m = new java.util.LinkedHashMap<>();
m.put("users", "id, phone, email, role, is_active, is_verified, updated_at");
m.put("user_roles", "user_id, role");
m.put("student_profiles",
"id, user_id, resume_url, resume_original_name, resume_content_type, resume_size, updated_at");
m.put("resume_versions",
"id, user_id, version_no, status, file_storage_path, file_name, content_type, file_size, updated_at");
m.put("applications", "id, job_id, student_user_id, status, updated_at");
m.put("jobs", "id, company_id, status, updated_at");
m.put("companies", "id, owner_user_id, verified, name, updated_at");
m.put("auth_tokens", "id, user_id, session_id, revoked, consumed_at, revoked_at, version, created_at");
m.put("audit_logs", "id, user_id, action, entity_type, entity_id, details, created_at");
m.put("candidate_dimension_scores",
"id, student_user_id, dimension, period, score, status, weight, rules_version, calculated_at, version");
m.put("notifications", "id, user_id, type, title, is_read, read_at, created_at");
m.put("messages", "id, conversation_id, sender_user_id, content, status, recalled_at, version, created_at");
m.put("scoring_risk_events",
"id, rule_config_id, subject_user_id, source_action, severity, occurrence_count, status, detected_at, version");
m.put("scoring_risk_actions", "id, risk_event_id, operator_user_id, action, reason_code, created_at");
m.put("talent_invitations",
"id, company_id, job_id, student_user_id, status, expires_at, decided_at, version, created_at");
return m;
}
/**
* 行级快照:按选定字段排序后取全部行,做深比较。
*
* <p>不使用 COUNT / SUM(id) / CRC32(id) 这类弱摘要;查询异常直接抛出使测试失败,
* 不允许被吞掉后写入占位值。</p>
*/
private java.util.Map<String, java.util.List<java.util.Map<String, Object>>> snapshot() {
java.util.Map<String, java.util.List<java.util.Map<String, Object>>> out = new java.util.LinkedHashMap<>();
for (java.util.Map.Entry<String, String> e : digestColumns().entrySet()) {
String columns = e.getValue();
String sql = "SELECT " + columns + " FROM " + e.getKey() + " ORDER BY " + columns;
out.put(e.getKey(), jdbcTemplate.queryForList(sql));
}
return out;
}
@Test
void companyDownloadHasNoSideEffectsOnBusinessTables() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
// 先取 token:测试夹具在签发时会写入 auth_tokens/users,必须发生在基线快照之前,
// 否则夹具自身的记录会被误判为「下载接口的副作用」。
String token = companyToken();
var before = snapshot();
assertThat(perform(token)).as("授权通过应 200").isEqualTo(200);
var after = snapshot();
for (String table : before.keySet()) {
assertThat(after.get(table))
.as(table + " 不得因企业下载而变化(逐行内容比较)")
.isEqualTo(before.get(table));
}
}
/**
* 反证:摘要必须能发现单行变化,否则前面的「零变化」断言没有说服力。
*/
@Test
void snapshotDetectsSingleRowChange() {
var before = snapshot();
Application app = applicationRepository.findById(applicationId).orElseThrow();
String original = app.getStatus();
app.setStatus(original.equals("INTERVIEW") ? "HIRED" : "INTERVIEW");
applicationRepository.saveAndFlush(app);
try {
var after = snapshot();
assertThat(after.get("applications"))
.as("单行 status 变化必须被摘要发现").isNotEqualTo(before.get("applications"));
} finally {
Application restore = applicationRepository.findById(applicationId).orElseThrow();
restore.setStatus(original);
applicationRepository.saveAndFlush(restore);
}
}
@Test
void snapshotCoversEveryRequiredTableAndColumn() {
var snap = snapshot();
for (String table : new String[]{
"users", "user_roles", "student_profiles", "resume_versions", "applications",
"jobs", "companies", "auth_tokens", "audit_logs", "candidate_dimension_scores",
"notifications", "messages", "scoring_risk_events", "scoring_risk_actions",
"talent_invitations"}) {
assertThat(snap).as("必须覆盖 " + table).containsKey(table);
}
// 抽查关键字段确实被取到(而非空 Map)
assertThat(snap.get("applications").get(0)).containsKeys("status", "updated_at");
assertThat(snap.get("auth_tokens").get(0)).containsKeys("revoked", "version");
}
// ── 工具 ───────────────────────────────────────────────────
private int perform(String token) {
try {
return mockMvc.perform(get(url()).header("Authorization", token))
.andReturn().getResponse().getStatus();
} catch (Exception e) {
return -1;
}
}
private int performNoAuth() {
try {
return mockMvc.perform(get(url())).andReturn().getResponse().getStatus();
} catch (Exception e) {
return -1;
}
}
}