From 06a1081bdd4f48dae98a6f5540f7f729728e36b7 Mon Sep 17 00:00:00 2001 From: windyboy Date: Tue, 8 Sep 2026 15:54:50 +0800 Subject: [PATCH] =?UTF-8?q?test(migration):=20=E5=B8=A6=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=20V1.1=E2=86=92=E6=9C=80=E6=96=B0=E5=8D=87=E7=BA=A7=E9=93=BE?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=20+=20DELY=20Apply=20=E6=8B=92=E7=BB=9D=20(A?= =?UTF-8?q?CM2-29=20P2-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlywayMigrationTest 新增升级链用例:隔离 schema 迁至 V1.1.0、写入含 *_TXT 的存量行、一路升级到最新——断言标量数据存活、v2 追踪列默认就位、 display 视图/backfill_todo/明细表可用、*_TXT 列按已知损失边界消失(评审 F6: 升级前必须导出或重放,测试固化该边界) - L2 收尾:DELY 无协议序号属性(DLNO 非法)——SEQ_ATTR 移除 DELY,Apply 对 DELY fail fast 拒绝(语义矩阵 §2 同步口径) - 级联回归已在 P2-2 落地(deleteByFlids/deleteDiffByDay 明细级联断言) 验证:MSGX_PG_PORT=5433 真实 PG 全量 107 用例 0 失败 0 跳过 --- .../domain/flight/FlightStateEngine.kt | 6 +- .../domain/flight/FlightStateEngineTest.kt | 23 +++++ .../persistence/jdbc/FlywayMigrationTest.kt | 89 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngine.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngine.kt index 90f01bf..9623be8 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngine.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngine.kt @@ -31,12 +31,12 @@ object FlightStateEngine { "CLDT" to "CLNO", "PSDT" to "PSNO", "CHDT" to "CHNO", - "DELY" to "DLNO", "ABTM" to "ASNO", "CHOT" to "CSNO", "ROUT" to "RTNO", "ERUT" to "RTNO", ) + // 注意:DELY 无协议序号属性(DLNO 非法)→ 不支持 Apply;清除走空数组 Replace(空集) /** DNLD/FLOP 字段集 → 命令(出现即 Set/Replace;未出现即 Unchanged;序号 0 条目 = 显式清除标记)。 */ fun commandsFromFields(flid: String, fields: FlightFields, snapshotReplace: Boolean): FlightFieldCommands { @@ -96,8 +96,10 @@ object FlightStateEngine { is CollectionCommand.Replace -> baseCollections[key] = cmd.items.toMutableList() CollectionCommand.Clear -> baseCollections.remove(key) is CollectionCommand.Apply -> { + val seqAttr = SEQ_ATTR[key] + ?: throw IllegalArgumentException("$key does not support Apply: protocol defines no source sequence attribute") val list = baseCollections.getOrPut(key) { mutableListOf() }.toMutableList() - val idx = list.indexOfFirst { it[SEQ_ATTR[key] ?: ""] == cmd.sourceSeq } + val idx = list.indexOfFirst { it[seqAttr] == cmd.sourceSeq } if (idx >= 0) { list[idx] = cmd.item } else { diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt index 66a36d3..1df0187 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt @@ -143,6 +143,29 @@ class FlightStateEngineTest { assertEquals(emptyList>(), next.collections["GTDT"]) } + @Test + fun `apply on DELY fails fast because protocol defines no sequence attribute`() { + val current = FlightNextState( + "F1", + emptyMap(), + mapOf("DELY" to listOf(mapOf("CODE" to "YY"))), + 1L, + "m1", + ) + val error = runCatching { + FlightStateEngine.apply( + current, + FlightFieldCommands( + "F1", + collections = mapOf("DELY" to CollectionCommand.Apply(mapOf("CODE" to "ZZ"), "1")), + ), + "m2", + bumpVersion = true, + ) + }.exceptionOrNull() + assertTrue(error is IllegalArgumentException) + } + @Test fun `exception null payload translates to explicit clear and apply records cleared key`() { val current = FlightNextState( diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt index 784e360..49ca3f1 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt @@ -3,6 +3,8 @@ package com.gzzn.omms.msgexchange.infra.persistence.jdbc import com.gzzn.omms.msgexchange.support.PgTestSupport import org.flywaydb.core.Flyway import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test @@ -66,4 +68,91 @@ class FlywayMigrationTest { } } } + + /** + * P2-4:带数据的 V1.1.0 → 最新版本升级链验证(隔离 schema,不污染 public)。 + * V1.2.0 删除 *_TXT 集合列是「已知损失边界」(评审 F6):标量列与结构完整存活, + * 集合文本不可恢复——升级前必须完整导出或重放(docs/flight-state-design-v2.md §7)。 + */ + @Test + fun `upgrade chain from seeded V1_1_0 data keeps scalars and adds v2 tracking`() { + assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage()) + val schema = "upgrade_chain_test" + // 清理历史遗留的隔离 schema + DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn -> + conn.createStatement().use { it.execute("DROP SCHEMA IF EXISTS $schema CASCADE") } + } + + // 1. 迁移到 V1.1.0(含 *_TXT 集合文本列形态)并写入带数据行 + Flyway.configure() + .dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password) + .locations("classpath:db/migration") + .schemas(schema) + .defaultSchema(schema) + .target("1.1.0") + .load() + .migrate() + DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn -> + conn.createStatement().use { + it.execute( + """ + INSERT INTO $schema.flight_schd (flid, fday, flno, remc, gtdt_txt, created_at, updated_at) + VALUES ('UPG_FL_1', '2026-09-07', 'CA_UPG', 'legacy-note', '[{"GTNO":"1","GATE":"G1"}]', now(), now()) + """.trimIndent(), + ) + } + } + + // 2. 一路升级到最新版本 + val result = Flyway.configure() + .dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password) + .locations("classpath:db/migration") + .schemas(schema) + .defaultSchema(schema) + .load() + .migrate() + assertTrue(result.success) + + DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn -> + conn.createStatement().use { stmt -> + // 3. 标量数据存活;v2 追踪列就位(存量行 state_version 默认 0) + stmt.executeQuery( + "SELECT flno, remc, state_version, last_message_id FROM $schema.flight_schd WHERE flid = 'UPG_FL_1'", + ).use { rs -> + assertTrue(rs.next()) + assertEquals("CA_UPG", rs.getString("flno")) + assertEquals("legacy-note", rs.getString("remc")) + assertEquals(0L, rs.getLong("state_version")) + assertNull(rs.getString("last_message_id")) + } + + // 4. 已知损失边界:*_TXT 集合列已不存在(升级前须导出/重放,评审 F6) + stmt.executeQuery( + "SELECT column_name FROM information_schema.columns WHERE table_schema = '$schema' AND table_name = 'flight_schd' AND column_name LIKE '%\\_TXT'", + ).use { rs -> + assertFalse(rs.next(), "legacy *_TXT columns must be gone after V1.2.0 (documented lossy boundary)") + } + + // 5. v2 结构就位:display 视图可查询(存量行 gate_total=0)、backfill_todo 与明细表存在 + stmt.executeQuery( + "SELECT flno, gate_total FROM $schema.flight_schd_display WHERE flid = 'UPG_FL_1'", + ).use { rs -> + assertTrue(rs.next()) + assertEquals("CA_UPG", rs.getString("flno")) + assertEquals(0, rs.getInt("gate_total")) + } + stmt.executeQuery("SELECT COUNT(*) FROM $schema.backfill_todo").use { rs -> + assertTrue(rs.next()) + } + stmt.executeQuery("SELECT COUNT(*) FROM $schema.flight_gate WHERE flid = 'UPG_FL_1'").use { rs -> + assertTrue(rs.next()) + assertEquals(0, rs.getInt(1)) + } + } + } + + DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn -> + conn.createStatement().use { it.execute("DROP SCHEMA IF EXISTS $schema CASCADE") } + } + } }