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.
299 lines
14 KiB
299 lines
14 KiB
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 查询副作用的**行级**证据基类。
|
|
*
|
|
* <p>证明公开搜索({@link JobService#searchJobs} 与
|
|
* {@link JobSearchSpecification#searchJobs} 的真实 Repository 路径)只读,
|
|
* 不会改写岗位状态、企业、用户或其他业务表。</p>
|
|
*
|
|
* <p>做法:在 jobs、companies、users、applications、candidate_dimension_scores、
|
|
* notifications、conversations、messages、audit_logs 九张表上取**行级稳定字段快照**,
|
|
* 执行完整搜索矩阵后再次取快照,逐表逐行逐字段比较。只比较 COUNT / SUM / CRC32
|
|
* 无法证明“没有某一行被改写”,因此这里不做聚合比较。</p>
|
|
*
|
|
* <p>快照比较本身也可能失效(例如永远返回相等),所以另有三条**反证测试**:人为改字段、
|
|
* 插行、删行,必须被快照发现。</p>
|
|
*
|
|
* <p>本基类不绑定数据库:H2 用例与真实 MySQL 用例各自继承并断言同一组行为,
|
|
* 从而形成跨库逐项对账。</p>
|
|
*/
|
|
abstract class JobSearchQuerySideEffectTestBase {
|
|
|
|
private static final AtomicLong SEQ = new AtomicLong(74000);
|
|
private static final int MAX_RESULTS = 50;
|
|
|
|
/** 清理顺序:先删有外键指向其他受检表的行,再删主表。 */
|
|
private static final List<String> 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<Long> jobIds = new ArrayList<>();
|
|
private final List<Long> companyIds = new ArrayList<>();
|
|
private final List<Long> userIds = new ArrayList<>();
|
|
private final Map<String, List<Long>> 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<Long> 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<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> before = snapshot.capture();
|
|
Long jobId = jobIds.get(0);
|
|
Map<String, Object> 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<String, List<Map<String, Object>>> before = snapshot.capture();
|
|
|
|
insertNotification(tag + "-反证插入");
|
|
|
|
assertThat(BusinessRowSnapshot.describeDifference(before, snapshot.capture()))
|
|
.as("反证:新增一行必须被快照发现")
|
|
.contains("notifications:");
|
|
}
|
|
|
|
@Test
|
|
void snapshotReportsDeletedRow() {
|
|
Long temporaryId = insertNotification(tag + "-反证删除");
|
|
Map<String, List<Map<String, Object>>> 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<String, Object> rowById(Map<String, List<Map<String, Object>>> 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 <T, ID> void deleteAllById(JpaRepository<T, ID> repository, List<ID> ids) {
|
|
if (!ids.isEmpty()) {
|
|
repository.deleteAllById(ids);
|
|
}
|
|
ids.clear();
|
|
}
|
|
}
|
|
|