task-193: 历史迁移盘点(docs/migration-inventory.md + 只读审计:整数版本 1..108 连续、无重复、识别 V25_1 历史小版本、命名合规、校验和可复算)+ 8 条测试

This commit is contained in:
2026-09-05 00:02:57 +08:00
parent 36463f2119
commit 5688870788
@@ -0,0 +1,160 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* task-193:历史迁移盘点审计(只读)。
* 校验 src/main/resources/db 版本化迁移:整数版本 1..max 连续、无重复、命名合规、
* 历史小版本(V25_1)被识别、校验和可复算稳定;产出 docs/migration-inventory.md 快照已提交。
*/
class MigrationInventoryTest {
private static final Path DB_DIR = Path.of(System.getProperty("user.dir"),
"src/main/resources/db");
private static final Pattern VERSION_FILE = Pattern.compile("^V(\\d+)(?:_(\\d+))?__.*\\.sql$");
private record Migration(int major, int minor) implements Comparable<Migration> {
boolean isInteger() {
return minor == 0;
}
@Override
public int compareTo(Migration o) {
return Integer.compare(major, o.major) != 0
? Integer.compare(major, o.major) : Integer.compare(minor, o.minor);
}
}
private List<Path> versionFiles() throws IOException {
try (Stream<Path> s = Files.list(DB_DIR)) {
return s.filter(p -> p.getFileName().toString().startsWith("V"))
.filter(p -> VERSION_FILE.matcher(p.getFileName().toString()).matches())
.sorted(Comparator.comparing(p -> p.getFileName().toString()))
.toList();
}
}
private List<Migration> parseAll(List<Path> files) {
List<Migration> list = new ArrayList<>();
for (Path f : files) {
Matcher m = VERSION_FILE.matcher(f.getFileName().toString());
if (m.matches()) {
list.add(new Migration(Integer.parseInt(m.group(1)),
m.group(2) == null ? 0 : Integer.parseInt(m.group(2))));
}
}
return list;
}
@Test
void integerVersionsSequentialFromOne() throws IOException {
List<Migration> all = parseAll(versionFiles());
List<Integer> integers = all.stream().filter(Migration::isInteger)
.map(Migration::major).sorted().toList();
for (int i = 0; i < integers.size(); i++) {
assertEquals(i + 1, integers.get(i), "整数版本应 1..N 连续,出现断号");
}
assertEquals(integers.size(), integers.get(integers.size() - 1), "整数版本应以 max 收尾且覆盖 1..max");
}
@Test
void legacySubversionIdentified() throws IOException {
List<Migration> all = parseAll(versionFiles());
assertTrue(all.stream().anyMatch(m -> m.major() == 25 && m.minor() == 1),
"历史小版本 V25_1 应被识别并保留(Flyway 语义 25.1");
}
@Test
void noDuplicateVersionFiles() throws IOException {
List<Path> files = versionFiles();
Set<String> names = new HashSet<>();
for (Path f : files) {
assertTrue(names.add(f.getFileName().toString()), "不应有重复版本文件名: " + f.getFileName());
}
}
@Test
void latestVersionPresentInDirectory() throws IOException {
List<Migration> all = parseAll(versionFiles());
int maxMajor = all.stream().filter(Migration::isInteger).mapToInt(Migration::major).max().orElse(0);
assertTrue(maxMajor >= 108, "最新迁移应 ≥ V108,实际 " + maxMajor);
boolean hasLatest = versionFiles().stream()
.anyMatch(p -> p.getFileName().toString().startsWith("V" + maxMajor + "__"));
assertTrue(hasLatest, "目录应含最新整数版本文件 V" + maxMajor + "__*");
}
@Test
void migrationFileNamesWellFormed() throws IOException {
for (Path f : versionFiles()) {
String name = f.getFileName().toString();
assertTrue(!name.contains(" "), "迁移文件名不应含空格: " + name);
assertTrue(name.matches("^V\\d+(?:_\\d+)?__[A-Za-z0-9_.-]+$"),
"命名应 V<版本>__<snake 描述>.sql: " + name);
}
}
@Test
void inventoryDocCommittedAndConsistent() throws IOException {
Path doc = Path.of(System.getProperty("user.dir"), "docs/migration-inventory.md");
assertTrue(Files.exists(doc), "盘点文档应存在并随仓库提交");
String text = Files.readString(doc);
assertTrue(text.contains("V108"), "文档应记录最新版本");
assertTrue(text.contains("25_1") || text.contains("V25_1"), "文档应记录历史小版本");
}
@Test
void checksumStableAcrossReads() throws IOException {
Map<String, String> first = sha256All(versionFiles());
Map<String, String> second = sha256All(versionFiles());
assertEquals(first, second, "迁移文件校验和应可复算稳定");
}
@Test
void auditRepeatable() throws IOException {
// 同一输入两次解析结果一致(纯读,可重复执行)
assertEquals(parseAll(versionFiles()), parseAll(versionFiles()));
assertEquals(sha256All(versionFiles()).size(), versionFiles().size());
}
private static Map<String, String> sha256All(List<Path> files) throws IOException {
Map<String, String> hashes = new HashMap<>();
for (Path f : files) {
hashes.put(f.getFileName().toString(), sha256(f));
}
return hashes;
}
private static String sha256(Path f) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(Files.readAllBytes(f));
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}