From 568887078857b30ccb148afb0522c5786e1ea495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 00:02:57 +0800 Subject: [PATCH] =?UTF-8?q?task-193:=20=E5=8E=86=E5=8F=B2=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E7=9B=98=E7=82=B9=EF=BC=88docs/migration-inventory.md?= =?UTF-8?q?=20+=20=E5=8F=AA=E8=AF=BB=E5=AE=A1=E8=AE=A1=EF=BC=9A=E6=95=B4?= =?UTF-8?q?=E6=95=B0=E7=89=88=E6=9C=AC=201..108=20=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E3=80=81=E6=97=A0=E9=87=8D=E5=A4=8D=E3=80=81=E8=AF=86=E5=88=AB?= =?UTF-8?q?=20V25=5F1=20=E5=8E=86=E5=8F=B2=E5=B0=8F=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E3=80=81=E5=91=BD=E5=90=8D=E5=90=88=E8=A7=84=E3=80=81=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=92=8C=E5=8F=AF=E5=A4=8D=E7=AE=97=EF=BC=89+=208=20?= =?UTF-8?q?=E6=9D=A1=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/MigrationInventoryTest.java | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/config/MigrationInventoryTest.java diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/MigrationInventoryTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/MigrationInventoryTest.java new file mode 100644 index 00000000..448e60cd --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/MigrationInventoryTest.java @@ -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 { + 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 versionFiles() throws IOException { + try (Stream 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 parseAll(List files) { + List 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 all = parseAll(versionFiles()); + List 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 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 files = versionFiles(); + Set names = new HashSet<>(); + for (Path f : files) { + assertTrue(names.add(f.getFileName().toString()), "不应有重复版本文件名: " + f.getFileName()); + } + } + + @Test + void latestVersionPresentInDirectory() throws IOException { + List 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<版本>__.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 first = sha256All(versionFiles()); + Map 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 sha256All(List files) throws IOException { + Map 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); + } + } +}