package com.mydreamjob.job; 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.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; import java.math.BigDecimal; import java.sql.PreparedStatement; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import static org.assertj.core.api.Assertions.assertThat; /** * Q01 §7 查询副作用的**行级**证据基类。 * *

证明公开搜索({@link JobService#searchJobs} 与 * {@link JobSearchSpecification#searchJobs} 的真实 Repository 路径)只读, * 不会改写岗位状态、企业、用户或其他业务表。

* *

做法:在 jobs、companies、users、applications、candidate_dimension_scores、 * notifications、conversations、messages、audit_logs 九张表上取**行级稳定字段快照**, * 执行完整搜索矩阵后再次取快照,逐表逐行逐字段比较。只比较 COUNT / SUM / CRC32 * 无法证明“没有某一行被改写”,因此这里不做聚合比较。

* *

快照比较本身也可能失效(例如永远返回相等),所以另有三条**反证测试**:人为改字段、 * 插行、删行,必须被快照发现。

* *

本基类不绑定数据库:H2 用例与真实 MySQL 用例各自继承并断言同一组行为, * 从而形成跨库逐项对账。

*/ abstract class JobSearchQuerySideEffectTestBase { private static final AtomicLong SEQ = new AtomicLong(74000); private static final int MAX_RESULTS = 50; /** 清理顺序:先删有外键指向其他受检表的行,再删主表。 */ private static final List AUXILIARY_TABLES = List.of( "messages", "conversations", "notifications", "applications", "candidate_dimension_scores", "audit_logs"); @Autowired protected JobService jobService; @Autowired protected JobRepository jobRepository; @Autowired protected CompanyRepository companyRepository; @Autowired protected UserRepository userRepository; @Autowired protected JdbcTemplate jdbcTemplate; private final List jobIds = new ArrayList<>(); private final List companyIds = new ArrayList<>(); private final List userIds = new ArrayList<>(); private final Map> auxiliaryIds = new LinkedHashMap<>(); private BusinessRowSnapshot snapshot; private String tag; private String city; private Long companyA; private Long ownerA; private Long student; @BeforeEach void setUpBusinessFixtures() { snapshot = new BusinessRowSnapshot(jdbcTemplate); tag = "Q01X" + SEQ.incrementAndGet(); city = tag + "CITY"; ownerA = createUser("甲", User.Role.COMPANY); Long ownerB = createUser("乙", User.Role.COMPANY); student = createUser("学生", User.Role.STUDENT); companyA = createCompany(ownerA, "甲"); Long companyB = createCompany(ownerB, "乙"); createJob(companyA, JobStatus.PUBLISHED.name(), 10000, 20000); createJob(companyA, JobStatus.PUBLISHED.name(), 10000, null); createJob(companyA, JobStatus.DRAFT.name(), 10000, 20000); createJob(companyB, JobStatus.CLOSED.name(), 10000, 20000); seedAuxiliaryRows(); } @AfterEach void tearDownBusinessFixtures() { for (String table : AUXILIARY_TABLES) { List ids = auxiliaryIds.get(table); if (ids == null || ids.isEmpty()) { continue; } String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); jdbcTemplate.update("DELETE FROM " + table + " WHERE id IN (" + placeholders + ")", ids.toArray()); } auxiliaryIds.clear(); deleteAllById(jobRepository, jobIds); deleteAllById(companyRepository, companyIds); deleteAllById(userRepository, userIds); } // ── 主证据:搜索不产生任何业务行改动 ──────────────────────── @Test void searchQueriesDoNotChangeBusinessRows() { Map>> before = snapshot.capture(); for (String table : BusinessRowSnapshot.tables()) { assertThat(before.get(table)) .as("受检表 %s 必须在测试库中有行,否则前后比较是空比较", table) .isNotEmpty(); } long matched = runFullSearchMatrix(); assertThat(matched).as("搜索矩阵必须真的命中数据,否则副作用证据是空跑").isPositive(); assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) .as("公开搜索全矩阵执行前后,九张受检表的行级稳定字段必须逐行逐字段一致") .isEmpty(); } // ── 反证:快照必须能发现改动,否则主证据不成立 ────────────── @Test void snapshotReportsRowFieldUpdate() { Map>> before = snapshot.capture(); Long jobId = jobIds.get(0); Map original = rowById(before, "jobs", jobId); jdbcTemplate.update("UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?", JobStatus.CLOSED.name(), original.get("updated_at"), jobId); assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) .as("反证:同一行的字段被改写必须被快照发现") .contains("jobs:"); jdbcTemplate.update("UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?", original.get("status"), original.get("updated_at"), jobId); assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) .as("反证:把字段与 updated_at 一并还原后,快照必须重新完全一致(说明差异定位精确)") .isEmpty(); } @Test void snapshotReportsInsertedRow() { Map>> before = snapshot.capture(); insertNotification(tag + "-反证插入"); assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) .as("反证:新增一行必须被快照发现") .contains("notifications:"); } @Test void snapshotReportsDeletedRow() { Long temporaryId = insertNotification(tag + "-反证删除"); Map>> before = snapshot.capture(); jdbcTemplate.update("DELETE FROM notifications WHERE id = ?", temporaryId); auxiliaryIds.get("notifications").remove(temporaryId); assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture())) .as("反证:删除一行必须被快照发现") .contains("notifications:"); } // ── 搜索矩阵 ──────────────────────────────────────────────── /** 覆盖全部过滤条件 × 全部排序 × 分页 × 无结果 × 聚合筛选项,返回命中的总行数。 */ private long runFullSearchMatrix() { long matched = 0; for (String sort : new String[]{"latest", "relevance", "salary", null}) { matched += jobService.searchJobs(null, city, null, null, null, null, sort, 0, MAX_RESULTS) .getTotalElements(); matched += jobService.searchJobs(tag + "岗位", city, "FULL_TIME", 10000, 20000, companyA, sort, 0, MAX_RESULTS).getTotalElements(); matched += jobService.searchJobs("%", city, null, 5000, 30000, null, sort, 0, 10) .getTotalElements(); matched += jobService.searchJobs("_", null, null, null, null, null, sort, 0, 10) .getTotalElements(); matched += jobService.searchJobs(tag + "不存在的关键词XYZ", city, null, null, null, null, sort, 0, 10).getTotalElements(); matched += jobService.searchJobs(null, city, null, null, null, null, sort, 1, 2) .getContent().size(); } jobService.getFilterOptions(); // 不经 Service,直接走 Specification 的真实 Repository 查询路径 matched += jobRepository.findAll( JobSearchSpecification.searchJobs(tag + "岗位", city, "FULL_TIME", 10000, 20000, companyA), PageRequest.of(0, MAX_RESULTS, Sort.by(Sort.Direction.DESC, "createdAt"))) .getNumberOfElements(); return matched; } // ── 夹具 ──────────────────────────────────────────────────── private Long createUser(String label, User.Role role) { Long id = userRepository.save(User.builder() .phone("16" + String.format("%09d", SEQ.incrementAndGet())) .nickname(tag + "-" + label) .role(role) .roles(Set.of(role)) .active(true).verified(true).build()).getId(); userIds.add(id); return id; } private Long createCompany(Long ownerId, String label) { Long id = companyRepository.save(Company.builder() .ownerUserId(ownerId).name(tag + "公司" + label).verified(true).build()).getId(); companyIds.add(id); return id; } private Long createJob(Long companyId, String status, Integer salaryMin, Integer salaryMax) { Long id = jobRepository.save(Job.builder() .companyId(companyId).title(tag + "岗位").description("描述").requirements("要求") .city(city).employmentType("FULL_TIME").salaryMin(salaryMin).salaryMax(salaryMax) .status(status).build()).getId(); jobIds.add(id); return id; } private void seedAuxiliaryRows() { Long publishedJobId = jobIds.get(0); insert("applications", "INSERT INTO applications (job_id, student_user_id, cover_letter, status) VALUES (?, ?, ?, ?)", publishedJobId, student, tag + "求职信", "SUBMITTED"); insert("candidate_dimension_scores", "INSERT INTO candidate_dimension_scores (student_user_id, dimension, period, score, status," + " sub_scores_json, weight, rules_version, calculated_at, version)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", student, "ABILITY", "2026-09", new BigDecimal("88.50"), "OK", "{\"q01\":true}", new BigDecimal("12.50"), tag, LocalDateTime.now(), 0L); insertNotification(tag + "-通知"); Long conversationId = insert("conversations", "INSERT INTO conversations (title, type, created_by) VALUES (?, ?, ?)", tag + "会话", "DIRECT", ownerA); insert("messages", "INSERT INTO messages (conversation_id, sender_user_id, content) VALUES (?, ?, ?)", conversationId, ownerA, tag + "消息"); insert("audit_logs", "INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details)" + " VALUES (?, ?, ?, ?, ?)", ownerA, "Q01_SEARCH_PROBE", "JOB", publishedJobId, tag + "审计"); } private Long insertNotification(String title) { return insert("notifications", "INSERT INTO notifications (user_id, type, title, content, is_read) VALUES (?, ?, ?, ?, ?)", student, "SYSTEM", title, tag + "内容", false); } /** 原生 INSERT,登记生成的 id 供精确清理,不依赖实体映射。 */ private Long insert(String table, String sql, Object... args) { KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(connection -> { PreparedStatement statement = connection.prepareStatement(sql, new String[]{"id"}); for (int i = 0; i < args.length; i++) { statement.setObject(i + 1, args[i]); } return statement; }, keyHolder); Long id = keyHolder.getKey().longValue(); auxiliaryIds.computeIfAbsent(table, key -> new ArrayList<>()).add(id); return id; } private static Map rowById(Map>> snapshot, String table, Long id) { return snapshot.get(table).stream() .filter(row -> id.equals(row.get("id"))) .findFirst() .orElseThrow(() -> new IllegalStateException("snapshot row missing: " + table + "#" + id)); } private static void deleteAllById(JpaRepository repository, List ids) { if (!ids.isEmpty()) { repository.deleteAllById(ids); } ids.clear(); } }