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