refactor(flight-state): 按 flight-state.md 审计定稿全量重构脚手架与 SQL (ACM2-31)

- SQL 基线 V1__flight_state_baseline.sql 整体取代 V1.0.0–V1.4.0:
  PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/FLIGHT_SCHD
  + 8 张资源明细表 + FLIGHT_ROUTE_POINT + SCHD_SNAP_LOG 留痕层
- 废除 FDAY 日代/SCHD_GEN/名单差删:OPERATION_DAY 不可变(应用层校验 +
  条件更新强化 §7.4),STATE 仅 ACTIVE/DELETED,物理清除只在历史归档后
- 处理器化:applyScheduleRecords(§5.1 七步同一事务,重放判定/整包
  DEAD(PROTOCOL)/归属冲突不落地)+ FLOP/FDEL/ADFT(tombstone 仅
  ACTIVE→DELETED,重复 FDEL 幂等不推进版本)
- 投递:KAFKA_SCHD 同 FLID 按最新 STATE_VERSION 合并,被压掉事件关闭,
  TOMBSTONE 发 null 值消息(键缺失=删除旧值 §7.3)
- 回填待办改为业务事务内预登记,消除提交后写待办的崩溃窗口(§7.2/§10)
- XML 解码改为 jackson-dataformat-xml 数据类直接映射(SIS 信封强类型,
  FLTR 开放标签泛型承载)
- 历史归档/物理清除顺序不可颠倒:归档确认成功集才物理删除,未接通删 0 条
- 移除 PUMP_JOB 队列/ReferenceService/FlightStoreDiffTool 等旧机制与测试,
  新增运营日/引擎/快照/FDEL/归档顺序不变性回归测试
This commit is contained in:
windyboy
2026-09-09 17:53:08 +08:00
parent 6af292d103
commit 879d658159
83 changed files with 3435 additions and 6355 deletions
@@ -58,18 +58,17 @@ class PipelineSmokeTest {
}
companion object {
/** 合法 META + FLOP,但未注册 Handler → FAILED(UNSUPPORTED) */
val UNSUPPORTED_FLOP_XML = """
/** 合法 META,但 TYPE 未在支持范围 → FAILED(UNSUPPORTED)(§9 */
val UNSUPPORTED_XML = """
<MSG>
<META><SNDR>AODB</SNDR><SEQN>1</SEQN><DTTM>20260908120000</DTTM><TYPE>FLOP</TYPE><STYP>DELY</STYP></META>
<FLOP><FLID>F1</FLID></FLOP>
<META><SNDR>AODB</SNDR><SEQN>1</SEQN><DTTM>20260908120000</DTTM><TYPE>XYZQ</TYPE><STYP>FOO</STYP></META>
</MSG>
""".trimIndent()
}
@Test
fun `accept then pump tick transitions message to FAILED UNSUPPORTED with backoff`() {
val receipt = controller.send(UNSUPPORTED_FLOP_XML)
val receipt = controller.send(UNSUPPORTED_XML)
assertNotNull(receipt.body()) // 受理 ID
val id = receipt.body()!!.toLong()
@@ -86,7 +85,7 @@ class PipelineSmokeTest {
@Test
fun `replay reopens failed UNSUPPORTED row to PENDING`() {
val receipt = controller.send(UNSUPPORTED_FLOP_XML)
val receipt = controller.send(UNSUPPORTED_XML)
val id = receipt.body()!!.toLong()
pump.tick()
val stub = ctx.getBean(StubProcState::class.java)
@@ -106,14 +105,14 @@ class PipelineSmokeTest {
val events = ctx.getBean(StubMsgEvents::class.java)
val port = ctx.getBean(StubDeliveryPort::class.java)
events.insertAll(listOf(
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", payloadJson = """{"FLID":"F1","v":"old"}"""),
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", payloadJson = """{"FLID":"F1","v":"new"}"""),
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", stateVersion = 1, payloadJson = """{"FLID":"F1","v":"old"}"""),
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", stateVersion = 2, payloadJson = """{"FLID":"F1","v":"new"}"""),
))
dispatcher.flushSchd()
assertEquals(1, port.sent.count { it.first == "schd" })
val payload = port.sent.first { it.first == "schd" }.second
assertEquals(1, port.sent.count { it.topic == "schd" })
val payload = port.sent.first { it.topic == "schd" }.payload!!
assertTrue(payload.contains("new") && !payload.contains("old"))
}
}
@@ -24,24 +24,24 @@ class JacksonXmlCodecTest {
</META>
<FLOP>
<FLID>121112312</FLID>
<FFID>CA-CA101-A-12DEC031345-D</FFID>
<FFID>CA-CA101-A-12DEC261345-D</FFID>
<GTDT GTNO="1">
<GATE>G28</GATE>
<PGOT>15DEC031805</PGOT>
<PGCT>15DEC031925</PGCT>
<GOTM>15DEC031825</GOTM>
<PGOT>15DEC261805</PGOT>
<PGCT>15DEC261925</PGCT>
<GOTM>15DEC261825</GOTM>
<GTYP>D</GTYP>
</GTDT>
<GTDT GTNO="2">
<GATE>G33</GATE>
<PGOT>15DEC031805</PGOT>
<PGCT>15DEC031925</PGCT>
<PGOT>15DEC261805</PGOT>
<PGCT>15DEC261925</PGCT>
<GTYP>I</GTYP>
</GTDT>
<GTDT GTNO="3">
<GATE>G23</GATE>
<PGOT>15DEC031805</PGOT>
<PGCT>15DEC031925</PGCT>
<PGOT>15DEC261805</PGOT>
<PGCT>15DEC261925</PGCT>
<GTYP>D</GTYP>
</GTDT>
</FLOP>
@@ -57,7 +57,7 @@ class JacksonXmlCodecTest {
val body = msg.body as FlopPayload
assertEquals("121112312", body.flid)
assertEquals("CA-CA101-A-12DEC031345-D", body.scalars["FFID"])
assertEquals("CA-CA101-A-12DEC261345-D", body.scalars["FFID"])
assertEquals(3, body.collections["GTDT"]!!.size)
assertEquals("G28", body.collections["GTDT"]!![0]["GATE"])
assertEquals("1", body.collections["GTDT"]!![0]["GTNO"])
@@ -12,7 +12,7 @@ import java.time.Duration
/**
* U03N02)验收:msgx.* 嵌套键「改值—回读」离线化。
* 个嵌套类(Pipeline/Schd/Identity/ConsistencyCheck)须标注 @ConfigurationProperties 才会被绑定;
* 个嵌套类(Pipeline/Schd/Identity)须标注 @ConfigurationProperties 才会被绑定;
* 未标注时下列覆盖值会静默回落 Kotlin 默认值(本测试即红)。仅注入配置 bean,不触碰仓储/DB。
* 注:Micronaut Test 5.x 移除 @Property 注解,改以 TestPropertyProvider 注入测试属性(需 PER_CLASS)。
*/
@@ -25,7 +25,6 @@ class PipelinePropsBindingTest : TestPropertyProvider {
"msgx.pipeline.max-attempts" to "9",
"msgx.schd.flush-limit" to "321",
"msgx.identity.include-day-boundary" to "true",
"msgx.consistency-check.daily-sample-ratio" to "0.05",
)
@Inject
@@ -37,12 +36,10 @@ class PipelinePropsBindingTest : TestPropertyProvider {
assertEquals(9, props.pipeline.maxAttempts)
assertEquals(321, props.schd.flushLimit)
assertTrue(props.identity.includeDayBoundary)
assertEquals(0.05, props.consistencyCheck.dailySampleRatio, 1e-9)
}
@Test
fun `top-level scalars still bind`() {
assertEquals(PipelineProps.Phase.A, props.phase)
assertEquals("msgexchangeapi", props.serviceName)
assertFalse(props.registerEureka) // application-test.yml 置 false
}
@@ -2,185 +2,131 @@ package com.gzzn.omms.msgexchange.delivery
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.stub.StubDeliveryPort
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
/**
* U06N03+ U08 闭环:schd 出口只走 flushSchd
* 批发送失败 → 整批 attempts 递增 + 指数退避(队首未到期不 claim),达上限整批 DEAD/DLQ
* 时间经可注入 ClockMutableClock),不依赖真实睡眠
* 投递调度(docs/flight-state.md §7.3):
* KAFKA_MSG 逐条 FIFOKAFKA_SCHD 唯一出口 flushSchd——同 FLID 未发事件按最新
* STATE_VERSION 合并;TOMBSTONE 发 null 值消息;失败退避重试、达上限 DEAD
*/
class DispatcherTickTest {
private class FakeRepo : MsgEventRepository {
val events = mutableListOf<MsgEvent>()
fun enqueue(e: MsgEvent) { events += e }
override fun insertAll(events: List<MsgEvent>) = events.map { it.eventId ?: 0L }
override fun headUnsent(target: String): MsgEvent? =
events.filter { it.target == target && it.state != EventStatus.SENT && it.state != EventStatus.DEAD }
.minByOrNull { it.eventId ?: Long.MAX_VALUE }
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
events.filter { it.target == target && it.state == EventStatus.PENDING }
.sortedBy { it.eventId ?: Long.MAX_VALUE }
.take(limit)
override fun markSent(eventId: Long) {
val i = events.indexOfFirst { it.eventId == eventId }
if (i >= 0) events[i] = events[i].copy(state = EventStatus.SENT)
}
override fun markAllSent(eventIds: List<Long>) = eventIds.forEach(::markSent)
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) {
val i = events.indexOfFirst { it.eventId == eventId }
if (i >= 0) events[i] = events[i].copy(state = EventStatus.PENDING, attempts = attempts, nextAttemptAt = nextAttemptAt)
}
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) {
val i = events.indexOfFirst { it.eventId == eventId }
if (i >= 0) events[i] = events[i].copy(state = EventStatus.DEAD, errorClass = errorClass, lastError = lastError,
attempts = attempts ?: events[i].attempts)
}
override fun insertSync(events: List<MsgEvent>) { syncInserted += events }
val syncInserted = mutableListOf<MsgEvent>()
}
private class FakePort : DeliveryPort {
val sent = mutableListOf<Pair<String, String>>()
var failTopic: String? = null
override fun sendKafka(topic: String, payloadJson: String) {
if (failTopic == topic) throw RuntimeException("send-fail:$topic")
sent += topic to payloadJson
}
override fun indexFlightHts(payloadJson: String) = Unit
}
private val clock = MutableClock(MutableClock.BASE)
private fun dispatcher(repo: FakeRepo, port: FakePort, p: PipelineProps = PipelineProps()): Dispatcher =
private fun dispatcher(repo: MsgEventRepository, port: DeliveryPort, p: PipelineProps = PipelineProps()): Dispatcher =
Dispatcher(repo, port, p, FailureScheduler(p, clock))
private fun ev(id: Long, target: String, key: String?, payload: String) =
MsgEvent(eventId = id, target = target, partitionKey = key, payloadJson = payload, state = EventStatus.PENDING)
private fun ev(id: Long, target: String, key: String, payload: String, version: Long = 0) =
MsgEvent(eventId = id, target = target, partitionKey = key, stateVersion = version, payloadJson = payload)
@Test
fun `schd flushed once via batch path, per-target tick never consumes it`() {
val repo = FakeRepo()
val port = FakePort()
val d = dispatcher(repo, port)
repo.enqueue(ev(1, Targets.KAFKA_MSG, null, """{"msg":1}"""))
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F1", """{"FLID":"F1","v":1}"""))
d.tick() // lastFlush=EPOCH → 首 tick 即到 flush 周期
assertEquals(1, port.sent.count { it.first == "msg" })
assertEquals(1, port.sent.count { it.first == "schd" })
assertEquals(EventStatus.SENT, repo.events.first { it.eventId == 2L }.state)
repo.enqueue(ev(4, Targets.KAFKA_SCHD, "F1", """{"FLID":"F1","v":4}"""))
d.tick() // 时钟未推进 → flush 周期未到;逐条循环不得消费 schd
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 4L }.state)
assertEquals(1, port.sent.count { it.first == "schd" })
}
@Test
fun `flushSchd aggregates latest per flight and marks all sent`() {
val repo = FakeRepo()
val port = FakePort()
val d = dispatcher(repo, port)
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", "old"))
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F2", "only"))
repo.enqueue(ev(3, Targets.KAFKA_SCHD, "F1", "new"))
d.flushSchd()
val payload = port.sent.first { it.first == "schd" }.second
assertTrue(payload.contains("new") && payload.contains("only") && !payload.contains("old"))
assertEquals(2, payload.removeSurrounding("[", "]").split(",").size) // 同 FLID 只发最新(矩阵 #7)
assertTrue(repo.events.all { it.state == EventStatus.SENT })
}
@Test
fun `flush failure schedules per-event backoff and retries after due`() {
val repo = FakeRepo()
val port = FakePort().apply { failTopic = "schd" }
val d = dispatcher(repo, port)
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
d.flushSchd()
val e1 = repo.events.single()
assertEquals(EventStatus.PENDING, e1.state)
assertEquals(1, e1.attempts)
assertNotNull(e1.nextAttemptAt) // 指数退避落库(attempts=1 → 首档)
assertEquals(0, port.sent.size)
clock.advance(1000) // 退避到期
port.failTopic = null
d.flushSchd()
assertEquals(EventStatus.SENT, repo.events.single().state)
assertEquals(1, port.sent.count { it.first == "schd" })
}
@Test
fun `repeated batch failures backoff grows and whole batch goes DEAD DLQ at limit`() {
val props = PipelineProps().apply { pipeline.maxAttempts = 2 }
val repo = FakeRepo()
val port = FakePort().apply { failTopic = "schd" }
fun `per-target tick delivers KAFKA_MSG only and never consumes schd`() {
val repo = StubMsgEvents()
val port = StubDeliveryPort()
val props = PipelineProps().apply { schd.flushPeriod = java.time.Duration.ofHours(1) }
repo.insertAll(
listOf(
ev(1, Targets.KAFKA_MSG, "F1", """{"flid":"F1"}"""),
ev(2, Targets.KAFKA_SCHD, "F1", """{"flid":"F1"}""", 1),
),
)
val d = dispatcher(repo, port, props)
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F2", """{"v":"2"}"""))
d.tick()
d.flushSchd() // 失败#1attempts=1,退避 backoff[1]=1000ms
val after1 = repo.events.map { it.attempts to it.state }
assertEquals(listOf(1 to EventStatus.PENDING, 1 to EventStatus.PENDING), after1)
assertTrue(repo.events.all { it.nextAttemptAt == MutableClock.BASE.plusMillis(1000) })
assertEquals(1, port.sent.count { it.topic == "msg" })
assertEquals(0, port.sent.count { it.topic == "schd" }) // N03schd 唯一出口 flushSchd
}
d.flushSchd() // 时钟未推进 → 队首未到期,不 claim(不推进 lastFlush
assertEquals(1, repo.events.first { it.eventId == 1L }.attempts)
clock.advance(1000)
d.flushSchd() // 失败#2attempts=2 == maxAttempts → 整批 DEAD
assertTrue(repo.events.all { it.state == EventStatus.DEAD && it.errorClass == ErrorClass.EXHAUSTED })
assertTrue(repo.events.all { it.attempts == 2 })
assertEquals(0, port.sent.size)
// DEAD 行不再参与 claim:空批 → 无新发送、无异常
@Test
fun `flushSchd aggregates latest version per flight and marks sent`() {
val repo = StubMsgEvents()
val port = StubDeliveryPort()
repo.insertAll(
listOf(
ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"old"}""", 1),
ev(2, Targets.KAFKA_SCHD, "F1", """{"v":"new"}""", 2),
ev(3, Targets.KAFKA_SCHD, "F2", """{"v":"f2"}""", 1),
),
)
val d = dispatcher(repo, port)
d.flushSchd()
assertEquals(0, port.sent.size)
val schd = port.sent.filter { it.topic == "schd" }
assertEquals(2, schd.size)
assertEquals(1, schd.count { it.key == "F1" && it.payload!!.contains("new") && !it.payload.contains("old") })
assertEquals(0, repo.rows.values.count { it.state.name == "PENDING" })
}
@Test
fun `tombstone is delivered as null value message`() {
val repo = StubMsgEvents()
val port = StubDeliveryPort()
repo.insertAll(
listOf(
MsgEvent(
eventId = 1, target = Targets.KAFKA_SCHD, partitionKey = "F1",
eventType = EventType.TOMBSTONE, stateVersion = 3, payloadJson = """{"flid":"F1","deleted":true}""",
),
),
)
val d = dispatcher(repo, port)
d.flushSchd()
assertEquals(1, port.tombstones.size)
assertEquals("F1", port.tombstones.single().key) // 整态键缺失表示删除旧值(§7.3)
assertNull(port.tombstones.single().payload)
}
@Test
fun `schd send failure schedules backoff and goes DEAD at limit`() {
val repo = StubMsgEvents()
val p = PipelineProps()
val d = dispatcher(repo, FailingSchdPort(), p)
repo.insertAll(listOf(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}""", 1)))
d.flushSchd()
assertEquals(1, repo.rows[1]!!.attempts)
repeat(p.pipeline.maxAttempts - 1) {
clock.advance(p.pipeline.backoffFor(repo.rows[1]!!.attempts) + 1)
d.flushSchd()
}
val final = repo.rows[1]!!
assertTrue(final.attempts >= p.pipeline.maxAttempts || final.state.name == "DEAD")
}
@Test
fun `KAFKA msg delivery unaffected while schd batch is retrying`() {
val props = PipelineProps().apply { pipeline.maxAttempts = 2 }
val repo = FakeRepo()
val port = FakePort().apply { failTopic = "schd" }
val d = dispatcher(repo, port, props)
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
repo.enqueue(ev(2, Targets.KAFKA_MSG, null, """{"m":1}"""))
val repo = StubMsgEvents()
val port = StubDeliveryPort()
val d = dispatcher(repo, port)
repo.insertAll(listOf(ev(1, Targets.KAFKA_MSG, "F9", """{"flid":"F9"}""")))
d.tick()
assertEquals(1, port.sent.count { it.topic == "msg" && it.key == "F9" })
}
}
assertEquals(EventStatus.SENT, repo.events.first { it.eventId == 2L }.state) // msg 正常
assertEquals(1, repo.events.first { it.eventId == 1L }.attempts) // schd 进退避
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 1L }.state)
/** schd 发送恒失败的端口(退避/终态闭环验证用)。 */
private class FailingSchdPort : DeliveryPort {
var calls = 0
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) {
calls++
throw IllegalStateException("broker-down")
}
override fun sendKafkaNull(topic: String, key: String) {
throw IllegalStateException("broker-down")
}
}
@@ -1,56 +0,0 @@
package com.gzzn.omms.msgexchange.delivery
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
/**
* ACMA-8 流程 3 / 兼容矩阵 #7(FIX):同一 FLID 多次变更只发最新一态;
* v4 显式“组内取 EVENT_ID 最大”,不依赖集合遍历序。
*/
class SchdAggregationTest {
private fun ev(flid: String, eventId: Long, payload: String) = MsgEvent(
eventId = eventId,
target = Targets.KAFKA_SCHD,
partitionKey = flid,
payloadJson = payload,
state = EventStatus.PENDING,
)
@Test
fun `same flight collapsed to max EVENT_ID`() {
val pending = listOf(
ev("F1", 1, """{"FLID":"F1","v":"old"}"""),
ev("F2", 2, """{"FLID":"F2","v":"only"}"""),
ev("F1", 3, """{"FLID":"F1","v":"new"}"""),
)
val latest = SchdAggregation.latestPerFlight(pending)
assertEquals(2, latest.size)
assertEquals("""{"FLID":"F1","v":"new"}""", latest.first { it.contains("F1") })
assertEquals("""{"FLID":"F2","v":"only"}""", latest.first { it.contains("F2") })
}
@Test
fun `order-independent grouping yields same latest per flight`() {
val a = listOf(
ev("F1", 1, "old"),
ev("F1", 2, "new"),
)
val b = a.reversed()
assertEquals(
SchdAggregation.latestPerFlight(a).toSet(),
SchdAggregation.latestPerFlight(b).toSet(),
)
}
@Test
fun `wire shape is FLTR JSON array`() {
val payload = SchdAggregation
.latestPerFlight(listOf(ev("F1", 1, "a"), ev("F2", 2, "b")))
.joinToString(",", "[", "]")
assertEquals("[a,b]", payload)
}
}
@@ -0,0 +1,47 @@
package com.gzzn.omms.msgexchange.domain
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.Test
import java.time.LocalDate
import java.time.ZoneId
/**
* 运营日计算(docs/flight-state.md §3.5):SODTddMMMyyHHmm+ 机场时区 + 业务切日边界。
*/
class OperationDayTest {
private val zone: ZoneId = ZoneId.of("Asia/Shanghai")
@Test
fun `computes operation day from SODT with zero cutoff`() {
val calc = OperationDayCalculator(zone, cutoffHour = 0)
assertEquals(LocalDate.of(2026, 12, 15), calc.compute("15DEC261723"))
}
@Test
fun `time before cutoff rolls back to previous operation day`() {
val calc = OperationDayCalculator(zone, cutoffHour = 4)
// 04:00 之前 → 前一运营日
assertEquals(LocalDate.of(2026, 12, 14), calc.compute("15DEC260230"))
// 04:00 整点起归属当日
assertEquals(LocalDate.of(2026, 12, 15), calc.compute("15DEC260400"))
}
@Test
fun `invalid or missing SODT returns null`() {
val calc = OperationDayCalculator(zone, 0)
assertNull(calc.compute(null))
assertNull(calc.compute(""))
assertNull(calc.compute("garbage"))
assertNull(calc.compute("99XXC12345"))
}
@Test
fun `cutoff outside 0 to 23 is clamped to 23`() {
val calc = OperationDayCalculator(zone, cutoffHour = 99)
// 越界收敛为 23:当日 00:00 早于 23 点 → 归属前一运营日
assertEquals(LocalDate.of(2026, 12, 14), calc.compute("15DEC260000"))
}
}
@@ -1,202 +1,113 @@
package com.gzzn.omms.msgexchange.domain.flight
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneId
/**
* 航班状态引擎(docs/flight-state.md §3.3/§5.2/§5.4/§6.1)不变量:
* 快照整体替换(缺失=清除)、DELETED 不被 SCHD 恢复、FLOP 增量保留缺失字段、
* §5.2 五项校验整包拒绝语义。
*/
class FlightStateEngineTest {
private val opDay = OperationDayCalculator(ZoneId.of("Asia/Shanghai"), 0)
private val day = LocalDate.of(2026, 12, 15)
@Test
fun `scalar unchanged does not modify existing value`() {
val current = FlightNextState("F1", mapOf("FLNO" to "CA100"), emptyMap(), 1L, "m1")
val next = FlightStateEngine.apply(
fun `snapshot replaces scalars and clears absent ones`() {
val current = FlightSnapshot(
"121", day, FlightState.ACTIVE, 5,
scalars = mapOf("FLNO" to "CA001", "REMC" to "old-note"),
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
)
val next = FlightStateEngine.snapshotState(
current,
FlightFieldCommands("F1", scalars = emptyMap()),
"m2",
bumpVersion = true,
ScheduleRecord("121", scalars = mapOf("FLNO" to "CA002")),
operationDay = day,
keepDeleted = false,
)
assertEquals("CA100", next.scalars["FLNO"])
assertEquals(2L, next.stateVersion)
assertEquals("CA002", next.scalars["FLNO"])
assertFalse(next.scalars.containsKey("REMC")) // 缺失 = Clear(§5.4
assertEquals(emptyList<Map<String, String>>(), next.collections["GTDT"]) // 缺失 = Replace 空集
assertEquals(6, next.stateVersion) // 每次成功写入 +1(§4
assertEquals(FlightState.ACTIVE, next.state)
}
@Test
fun `collection replace preserves order and source sequence`() {
val items = listOf(
mapOf("GTNO" to "1", "GATE" to "A1"),
mapOf("GTNO" to "3", "GATE" to "B2"),
mapOf("GTNO" to "7", "GATE" to "C3"),
fun `snapshot keeps DELETED state and flags revive conflict by caller`() {
val current = FlightSnapshot("121", day, FlightState.DELETED, 3, mapOf(), emptyMap())
val next = FlightStateEngine.snapshotState(
current, ScheduleRecord("121", mapOf("FLNO" to "CA001")), day, keepDeleted = true,
)
val next = FlightStateEngine.apply(
null,
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Replace(items))),
"m1",
bumpVersion = true,
)
assertEquals(3, next.collections["GTDT"]!!.size)
assertEquals("3", next.collections["GTDT"]!![1]["GTNO"])
assertEquals("C3", next.collections["GTDT"]!![2]["GATE"])
assertEquals(FlightState.DELETED, next.state) // §5.1 步骤 6:普通 SCHD 不恢复
assertEquals(4, next.stateVersion)
}
@Test
fun `collection clear removes prior entries`() {
val current = FlightNextState(
"F1",
emptyMap(),
mapOf("GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "A1"))),
1L,
"m1",
fun `flop merge retains absent scalars and collections`() {
val current = FlightSnapshot(
"121", day, FlightState.ACTIVE, 1,
scalars = mapOf("FLNO" to "CA001", "ESTT" to "15DEC261807"),
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
)
val next = FlightStateEngine.apply(
val next = FlightStateEngine.mergedState(
current,
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Clear)),
"m2",
bumpVersion = true,
MergeChange("121", scalars = mapOf("ESTT" to "15DEC261900")),
)
assertFalse(next.collections.containsKey("GTDT"))
assertEquals("15DEC261900", next.scalars["ESTT"])
assertEquals("CA001", next.scalars["FLNO"]) // 缺失 = 保留(§6.1
assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"])
assertEquals(2, next.stateVersion)
}
@Test
fun `same gate number different attributes both retained on replace`() {
val items = listOf(
mapOf("GTNO" to "1", "GATE" to "A1", "GTYP" to "D"),
mapOf("GTNO" to "1", "GATE" to "A1", "GTYP" to "I"),
fun `validation rejects RECS mismatch and duplicate flid and day outside scope`() {
val ok = FlightStateEngine.validateMessage(
1, listOf(ScheduleRecord("121", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
)
val next = FlightStateEngine.apply(
null,
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Replace(items))),
"m1",
bumpVersion = true,
)
assertEquals("D", next.collections["GTDT"]!![0]["GTYP"])
assertEquals("I", next.collections["GTDT"]!![1]["GTYP"])
}
assertTrue(ok is SnapshotValidation.Ok)
@Test
fun `incremental apply bumps state version from persisted value`() {
val current = FlightNextState("F1", mapOf("FLNO" to "CA100"), emptyMap(), 5L, "m1")
val next = FlightStateEngine.apply(
current,
FlightFieldCommands("F1", scalars = mapOf("STAT" to ScalarCommand.Set("DEP"))),
"m2",
bumpVersion = true,
)
assertEquals(6L, next.stateVersion)
}
val recsMismatch = FlightStateEngine.validateMessage(
2, listOf(ScheduleRecord("121", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
) as SnapshotValidation.Invalid
assertTrue(recsMismatch.flags.contains(SnapshotFlag.RECS_DROP))
@Test
fun `apply by source sequence updates single row`() {
val current = FlightNextState(
"F1",
emptyMap(),
mapOf("GTDT" to listOf(mapOf("GTNO" to "2", "GATE" to "OLD"))),
1L,
"m1",
)
val next = FlightStateEngine.apply(
current,
FlightFieldCommands(
"F1",
collections = mapOf(
"GTDT" to CollectionCommand.Apply(mapOf("GTNO" to "2", "GATE" to "NEW"), "2"),
),
val duplicate = FlightStateEngine.validateMessage(
2,
listOf(
ScheduleRecord("121", mapOf("SODT" to "15DEC261723")),
ScheduleRecord("121", mapOf("SODT" to "15DEC261723")),
),
"m2",
bumpVersion = true,
day, day, opDay,
)
assertEquals("NEW", next.collections["GTDT"]!![0]["GATE"])
assertTrue(duplicate is SnapshotValidation.Invalid)
val outOfScope = FlightStateEngine.validateMessage(
1, listOf(ScheduleRecord("121", mapOf("SODT" to "20DEC261723"))), day, day, opDay,
) as SnapshotValidation.Invalid
assertTrue(outOfScope.flags.contains(SnapshotFlag.DAY_MISMATCH)) // §5.2 覆盖范围校验
val empty = FlightStateEngine.validateMessage(0, emptyList(), day, day, opDay) as SnapshotValidation.Ok
assertTrue(empty.flags.contains(SnapshotFlag.EMPTY))
}
@Test
fun `zero sequence marker alone translates to explicit clear`() {
val commands = FlightStateEngine.commandsFromFields(
"F1",
mapOf("GTDT" to """[{"GTNO":"0"}]"""),
fun `validation rejects non numeric or oversized flid`() {
val bad = FlightStateEngine.validateMessage(
1, listOf(ScheduleRecord("ABC", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
)
assertEquals(CollectionCommand.Clear, commands.collections["GTDT"])
}
@Test
fun `zero sequence marker mixed with regular items fails fast instead of guessing`() {
val error = runCatching {
FlightStateEngine.commandsFromFields(
"F1",
mapOf("GTDT" to """[{"GTNO":"1","GATE":"A1"},{"GTNO":"0"}]"""),
)
}.exceptionOrNull()
assertTrue(error is IllegalArgumentException)
}
@Test
fun `empty array replaces collection with empty set keeping key semantics`() {
val current = FlightNextState(
"F1",
emptyMap(),
mapOf("GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "A1"))),
1L,
"m1",
assertTrue(bad is SnapshotValidation.Invalid)
val tooLong = FlightStateEngine.validateMessage(
1, listOf(ScheduleRecord("1234567890123", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
)
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("GTDT" to "[]"))
val next = FlightStateEngine.apply(current, commands, "m2", bumpVersion = true)
assertEquals(emptyList<Map<String, String>>(), 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(
"F1",
mapOf("FLNO" to "CA100", "FRET" to """{"REID":"R1","RSN":"diverted"}"""),
emptyMap(),
3L,
"m1",
)
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("FRET" to "null"))
assertEquals(ScalarCommand.Clear, commands.scalars["FRET"])
val next = FlightStateEngine.apply(current, commands, "m2", bumpVersion = true)
assertFalse(next.scalars.containsKey("FRET"))
assertTrue(next.clearedKeys.contains("FRET"))
assertEquals("CA100", next.scalars["FLNO"])
// 非空载荷仍为 Set,且清空后的下一跳 Set 恢复键(clearedKeys 不跨消息携带)
val restored = FlightStateEngine.apply(
next,
FlightStateEngine.commandsFromFields("F1", mapOf("FRET" to """{"REID":"R2"}""")),
"m3",
bumpVersion = true,
)
assertEquals("""{"REID":"R2"}""", restored.scalars["FRET"])
assertFalse(restored.clearedKeys.contains("FRET"))
}
@Test
fun `exception clear accepts null literal empty object and empty string`() {
for (payload in listOf("null", "{}", "")) {
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("FDIV" to payload))
assertEquals(ScalarCommand.Clear, commands.scalars["FDIV"], "payload=[$payload] must be Clear")
}
assertTrue(tooLong is SnapshotValidation.Invalid) // SIS §3.16.2 Number(1-12)
}
}
@@ -12,14 +12,16 @@ import org.junit.jupiter.api.Test
class HealthIndicatorsTest {
private class FakePort(private val pingResult: Boolean) : DeliveryPort {
override fun sendKafka(topic: String, payloadJson: String) = Unit
override fun indexFlightHts(payloadJson: String) = Unit
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaNull(topic: String, key: String) = Unit
override fun ping(): Boolean = pingResult
}
private class ThrowingPort : DeliveryPort {
override fun sendKafka(topic: String, payloadJson: String) = Unit
override fun indexFlightHts(payloadJson: String) = Unit
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaNull(topic: String, key: String) = Unit
override fun ping(): Boolean = throw RuntimeException("metadata fetch failed")
}
@@ -1,28 +0,0 @@
package com.gzzn.omms.msgexchange.infra.persistence
import com.fasterxml.jackson.databind.ObjectMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FlightFieldsJsonTest {
private val mapper = ObjectMapper()
@Test
fun `collection JSON is emitted as a native array`() {
val json = FlightFieldsJson.toJson(
mapOf("FLID" to "F1", "GTDT" to """[{"GTNO":"1","GATE":"A1"}]"""),
)
val root = mapper.readTree(json)
assertTrue(root["GTDT"].isArray)
assertEquals("A1", root["GTDT"][0]["GATE"].asText())
}
@Test
fun `malformed collection text stays a string instead of breaking delivery`() {
val root = mapper.readTree(FlightFieldsJson.toJson(mapOf("GTDT" to "legacy-value")))
assertTrue(root["GTDT"].isTextual)
assertEquals("legacy-value", root["GTDT"].asText())
}
}
@@ -1,815 +0,0 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
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.BeforeEach
import org.junit.jupiter.api.Test
import com.gzzn.omms.msgexchange.support.PgTestSupport
import com.zaxxer.hikari.HikariDataSource
import java.sql.DriverManager
import java.time.Instant
import java.time.ZoneId
import java.util.TimeZone
/**
* ACM2-28 FS6 + ACM2-29PostgreSQL 真实方言集成测试(JdbcFlightSchdRepository
* 验证:
* 1. DNLD 快照批处理写入(JDBC batch200 批次);
* 2. 增量 Upsert FDAY 保留策略(ON CONFLICT 保留原代,新插置 NULL);
* 3. 域化差删(按 FDAY 严格隔离);
* 4. SCHD_GEN SQL CAS(防并发断言);
* 5. 单事务原子回滚(崩溃无残留);
* 6. ES 历史清场 deleteByFlids 幂等删除;
* 7. ACM2-29 零子表:集合平铺槽位列写读(集合级全量替换/序号 0 清除/里程碑与航路紧凑列),
* 读侧视图重建 legacy 同构集合键;
* 8. I5PIPELINE_LOCK 行级单写者互斥与提交释放。
*/
class FlightSchdJdbcPgTest {
private lateinit var ds: HikariDataSource
private lateinit var repo: JdbcFlightSchdRepository
private val mapper = com.fasterxml.jackson.databind.ObjectMapper()
@BeforeEach
fun setUp() {
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
ds = HikariDataSource().apply {
jdbcUrl = PgTestSupport.jdbcUrl
username = PgTestSupport.user
password = PgTestSupport.password
driverClassName = "org.postgresql.Driver"
maximumPoolSize = 2
}
// 幂等前置迁移:全新容器(或库缺失 schema)时补齐 V1.0.0→V1.3.0
org.flywaydb.core.Flyway.configure()
.dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password)
.locations("classpath:db/migration")
.load()
.migrate()
repo = JdbcFlightSchdRepository(ds)
cleanup()
}
@AfterEach
fun tearDown() {
if (::ds.isInitialized) {
cleanup()
ds.close()
}
}
private fun cleanup() {
try {
val testFlids = ds.query(
"SELECT flid FROM flight_schd WHERE flid LIKE 'TEST_%'",
{},
) { rs -> rs.getString("flid") }
if (testFlids.isNotEmpty()) {
com.gzzn.omms.msgexchange.infra.persistence.jdbc.FlightDetailTables.deleteForFlids(ds, testFlids)
}
ds.update("DELETE FROM flight_schd WHERE flid LIKE 'TEST_%'") {}
ds.update("DELETE FROM schd_gen WHERE fday >= '2026-09-01'") {}
} catch (_: Exception) {
}
}
/** v2 写路径便捷构造:字段集 → 快照 nextState(版本顺序推进)。 */
private fun snapshotStates(
day: String,
flights: List<Pair<String, Map<String, String>>>,
version: Long = 1L,
messageId: String = "msg-test",
) = flights.map { (flid, fields) ->
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.apply(
null,
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.commandsFromFields(flid, fields),
messageId,
bumpVersion = false,
).copy(stateVersion = version)
}
@Test
fun `day diff preserves all details of migrated and unowned flights`() {
val day = "2026-09-07"
val otherDay = "2026-09-08"
val fields = mapOf("GTDT" to """[{"GTNO":"3","GATE":"G23"}]""")
val removed = "TEST_DIFF_REMOVED"
val moved = "TEST_DIFF_MOVED"
val unowned = "TEST_DIFF_ADFT"
repo.persistNextStates(day, snapshotStates(day, listOf(removed to fields)), snapshotReplace = true)
repo.persistNextStates(otherDay, snapshotStates(otherDay, listOf(moved to fields)), snapshotReplace = true)
repo.persistNextStates(null, snapshotStates(day, listOf(unowned to fields)), snapshotReplace = false)
val beforeMoved = repo.findByFlid(moved)
val beforeUnowned = repo.findByFlid(unowned)
JdbcPipelineTransactionManager(ds).inTransaction {
assertEquals(1, repo.deleteDiffByDay(day, listOf(removed, moved, unowned)))
}
assertNull(repo.findByFlid(removed))
assertTrue(FlightDetailTables.loadCollections(ds, removed).isEmpty())
assertEquals(beforeMoved, repo.findByFlid(moved))
assertEquals(beforeUnowned, repo.findByFlid(unowned))
}
@Test
fun `PG dialect - snapshot batch upsert, find, and domain diff delete`() {
val day = "2026-09-07"
// 构造 250 条记录跨越 JDBC batch 200 门限
val flights = (1..250).map { i ->
"TEST_FL_$i" to mapOf("FLID" to "TEST_FL_$i", "FLNO" to "CA$i")
}
repo.persistNextStates(day, snapshotStates(day, flights), snapshotReplace = true)
// 验证全量写入成功
val byDay = repo.findByDay(day)
assertEquals(250, byDay.size)
// 点查测试
val f1 = repo.findByFlid("TEST_FL_1")
assertNotNull(f1)
assertEquals("CA1", f1!!["FLNO"])
// 多键点查测试(分批查)
val subset = (1..50).map { "TEST_FL_$it" }
val map = repo.findByFlids(subset)
assertEquals(50, map.size)
// 域内差删:删除 1..50
val deleted = repo.deleteDiffByDay(day, subset)
assertEquals(50, deleted)
assertEquals(200, repo.findByDay(day).size)
assertNull(repo.findByFlid("TEST_FL_1"))
}
@Test
fun `PG dialect - incremental upsert preserves existing FDAY and sets NULL for new`() {
val day = "2026-09-07"
// 1. 快照写入 REG_01FDAY 为 2026-09-07
repo.persistNextStates(
day,
snapshotStates(day, listOf("TEST_REG_01" to mapOf("FLID" to "TEST_REG_01", "REMC" to "1"))),
snapshotReplace = true,
)
// 2. 增量更新 REG_01(已有行)与 ADFT_01(新行)
repo.persistNextStates(
null,
listOf("TEST_REG_01" to "2", "TEST_ADFT_01" to "1").map { (flid, remc) ->
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.apply(
null,
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.commandsFromFields(
flid, mapOf("FLID" to flid, "REMC" to remc),
),
"msg-inc-01",
bumpVersion = true,
)
},
snapshotReplace = false,
)
// 3. 验证 REG_01 内容更新但 FDAY 依然保留为 2026-09-07
val reg01 = ds.queryOne("SELECT fday, remc FROM flight_schd WHERE flid = 'TEST_REG_01'", {}) { rs ->
rs.getDate("fday")?.toString() to rs.getString("remc")
}
assertNotNull(reg01)
assertEquals("2026-09-07", reg01!!.first)
assertEquals("2", reg01.second)
// 4. 验证 ADFT_01 新插入行 FDAY 恒为 NULL
val adft01 = ds.queryOne("SELECT fday, remc FROM flight_schd WHERE flid = 'TEST_ADFT_01'", {}) { rs ->
rs.getDate("fday")?.toString() to rs.getString("remc")
}
assertNotNull(adft01)
assertNull(adft01!!.first)
// 5. 执行 2026-09-07 的差删,ADFT_01 绝不被误删
val deleted = repo.deleteDiffByDay(day, listOf("TEST_ADFT_01"))
assertEquals(0, deleted)
assertNotNull(repo.findByFlid("TEST_ADFT_01"))
}
@Test
fun `resource collections replace as a whole and zero sequence clears them losslessly`() {
val day = "2026-09-07"
val flid = "TEST_RESOURCE"
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
// 快照:两个登机门 → 明细表两行(保序保 GTNO)
repo.persistNextStates(
day,
listOf(
engine.apply(
null,
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"1","GATE":"A1"},{"GTNO":"2","GATE":"A2"}]""")),
"msg-res-1", bumpVersion = true,
),
),
snapshotReplace = true,
)
val gates = ds.query(
"SELECT ordinal, source_seq, gate FROM flight_gate WHERE flid = ? ORDER BY ordinal",
{ ps -> ps.setString(1, flid) },
) { rs -> Triple(rs.getInt("ordinal"), rs.getString("source_seq"), rs.getString("gate")) }
assertEquals(listOf(Triple(1, "1", "A1"), Triple(2, "2", "A2")), gates)
// 读侧明细权威视图:两个登机门完整读回
val view = repo.findByFlid(flid)!!["GTDT"]?.let { mapper.readTree(it) }
assertEquals(2, view!!.size())
assertEquals("A2", view[1]["GATE"].asText())
assertEquals("2", view[1]["GTNO"].asText())
// 增量 = 集合级全量替换:1 个登机门(非连续 GTNO=3 保真)
repo.persistNextStates(
null,
listOf(
engine.apply(
repo.findNextStateByFlid(flid),
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"3","GATE":"B1"}]""")),
"msg-res-2", bumpVersion = true,
),
),
snapshotReplace = false,
)
val replaced = mapper.readTree(repo.findByFlid(flid)!!["GTDT"])
assertEquals(1, replaced.size())
assertEquals("B1", replaced[0]["GATE"].asText())
assertEquals("3", replaced[0]["GTNO"].asText())
// 序号属性 "0" = 显式删除标记 → 集合清除,明细行删除,视图无 GTDT 键
repo.persistNextStates(
null,
listOf(
engine.apply(
repo.findNextStateByFlid(flid),
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"0"}]""")),
"msg-res-3", bumpVersion = true,
),
),
snapshotReplace = false,
)
assertNull(repo.findByFlid(flid)!!["GTDT"])
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
}
@Test
fun `milestone delay route and exception collections persist losslessly in v2 storage`() {
val day = "2026-09-07"
val flid = "TEST_SCALAR"
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
repo.persistNextStates(
day,
listOf(
engine.apply(
null,
engine.commandsFromFields(
flid,
mapOf(
"DELY" to """[{"CODE":"YY","STRT":"07SEP261605","DURA":"0200","REMC":"Flight Delayed"}]""",
"ROUT" to """[{"RTNO":"1","APCD":"ORD","SCAT":"07SEP261125","SCDT":"07SEP261315"},{"RTNO":"2","APCD":"MSP"}]""",
"ABTM" to """[{"ASNO":"1","ABDG":"B01","ABOP":"A","AOTM":"07SEP261700"}]""",
"FDIV" to """{"DDES":"PEK","DDIR":"TO","REMC":"weather"}""",
),
),
"msg-scalar-1", bumpVersion = true,
),
),
snapshotReplace = true,
)
// 库内明细表保序保源序号:延误、航路(route_kind 区分)、靠桥操作
val delay = ds.queryOne(
"SELECT source_seq, code, strt, remc FROM flight_delay WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("source_seq"), rs.getString("code"), rs.getString("strt"), rs.getString("remc")) }
assertEquals(listOf(null, "YY", "07SEP261605", "Flight Delayed"), delay)
val routes = ds.query(
"SELECT route_kind, source_seq, apcd, scdt FROM flight_route_point WHERE flid = ? ORDER BY ordinal",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("route_kind"), rs.getString("source_seq"), rs.getString("apcd"), rs.getString("scdt")) }
assertEquals(
listOf(
listOf("ROUT", "1", "ORD", "07SEP261315"),
listOf("ROUT", "2", "MSP", null),
),
routes,
)
val bridge = ds.queryOne(
"SELECT source_seq, abdg, abop, aotm FROM flight_bridge_op WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("source_seq"), rs.getString("abdg"), rs.getString("abop"), rs.getString("aotm")) }
assertEquals(listOf("1", "B01", "A", "07SEP261700"), bridge)
// 异常对象 = 主表前缀标量列;读侧视图重建 FDIV 键
val fdiv = ds.queryOne(
"SELECT fdiv_ddes, fdiv_ddir, fdiv_remc FROM flight_schd WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("fdiv_ddes"), rs.getString("fdiv_ddir"), rs.getString("fdiv_remc")) }
assertEquals(listOf("PEK", "TO", "weather"), fdiv)
val fields = repo.findByFlid(flid)!!
assertEquals("YY", mapper.readTree(fields["DELY"])[0]["CODE"].asText())
assertEquals(2, mapper.readTree(fields["ROUT"]).size())
assertEquals("A", mapper.readTree(fields["ABTM"])[0]["ABOP"].asText())
assertEquals("PEK", mapper.readTree(fields["FDIV"])["DDES"].asText())
// 增量:空延误数组 = 集合替换为空 → 明细行删除;航路整集合替换
repo.persistNextStates(
null,
listOf(
engine.apply(
repo.findNextStateByFlid(flid),
engine.commandsFromFields(
flid,
mapOf(
"DELY" to "[]",
"ROUT" to """[{"RTNO":"1","APCD":"CTU"}]""",
),
),
"msg-scalar-2", bumpVersion = true,
),
),
snapshotReplace = false,
)
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_delay WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
assertNull(repo.findByFlid(flid)!!["DELY"])
val replacedRoute = mapper.readTree(repo.findByFlid(flid)!!["ROUT"])
assertEquals(1, replacedRoute.size())
assertEquals("CTU", replacedRoute[0]["APCD"].asText())
}
@Test
fun `PIPELINE_LOCK row lock guards the writer transaction and releases on commit`() {
val txManager = JdbcPipelineTransactionManager(ds)
// 持锁事务内:另一连接 NOWAIT 获取同行锁必须立即失败(互斥生效)
assertEquals("held", txManager.inTransaction {
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { other ->
other.autoCommit = false
try {
other.prepareStatement(
"SELECT lock_key FROM pipeline_lock WHERE lock_key = 'FLIGHT_SCHD_WRITER' FOR UPDATE NOWAIT",
).use { ps -> ps.executeQuery() }
throw AssertionError("expected PIPELINE_LOCK contention")
} catch (_: java.sql.SQLException) {
// 期望:行锁已被写者事务持有
} finally {
other.rollback()
}
}
"held"
})
// 提交后锁释放:可再次进入,且种子行完整
assertEquals("again", txManager.inTransaction { "again" })
val owner = ds.queryOne(
"SELECT owner_info FROM pipeline_lock WHERE lock_key = 'FLIGHT_SCHD_WRITER'",
{},
) { rs -> rs.getString("owner_info") }
assertNull(owner)
}
@Test
fun `PG dialect - SCHD_GEN SQL CAS version advance and conflict rejection`() {
val day = "2026-09-07"
// 初始 CAS: expected = 0,版本推进至 1
val gen1 = FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_A", "TEST_B"))
val ok1 = repo.putGenIfVersion(day, 0L, gen1)
assertTrue(ok1)
val stored1 = repo.getGen(day)
assertNotNull(stored1)
assertEquals(1L, stored1!!.version)
assertEquals(setOf("TEST_A", "TEST_B"), stored1.flids)
// 成功 CAS: expected = 1,版本推进至 2
val gen2 = FlightSchdRepository.GenMeta(day, 2L, setOf("TEST_A", "TEST_C"))
val ok2 = repo.putGenIfVersion(day, 1L, gen2)
assertTrue(ok2)
assertEquals(2L, repo.getGen(day)!!.version)
// 冲突 CAS: expected 依然传 1(已过期的旧版本),应被拒并返回 false
val genConflict = FlightSchdRepository.GenMeta(day, 3L, setOf("TEST_CONFLICT"))
val conflictResult = repo.putGenIfVersion(day, 1L, genConflict)
assertFalse(conflictResult)
// 确认版本未被非法篡改,仍为 2
assertEquals(2L, repo.getGen(day)!!.version)
}
@Test
fun `PG dialect - Transaction rollback leaves zero partial rows on failure`() {
val day = "2026-09-07"
try {
ds.withTransaction {
repo.persistNextStates(
day,
snapshotStates(day, listOf("TEST_TX_01" to mapOf("REMC" to "tx"))),
snapshotReplace = true,
)
repo.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_TX_01")))
// 模拟事务内部抛出异常
throw IllegalStateException("forced-abort-for-rollback-test")
}
} catch (_: IllegalStateException) {
// 异常捕获
}
// 断言:由于事务原子回滚,数据库中零残留
assertNull(repo.findByFlid("TEST_TX_01"))
assertNull(repo.getGen(day))
}
@Test
fun `PG dialect - ES history sweep deleteByFlids idempotent batch execution`() {
val day = "2026-09-07"
val flids = (1..50).map { "TEST_SWEEP_$it" }
repo.persistNextStates(
day,
snapshotStates(day, flids.map { it to mapOf("FLID" to it, "GTDT" to """[{"GTNO":"1","GATE":"A"}]""") }),
snapshotReplace = true,
)
assertEquals(50, repo.findByFlids(flids).size)
assertEquals(50, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid LIKE 'TEST_SWEEP_%'", {}) { rs -> rs.getInt(1) }.first())
// 第一次分批删除:主表与明细级联删除
val deleted1 = repo.deleteByFlids(flids.toSet())
assertEquals(50, deleted1)
assertEquals(0, repo.findByFlids(flids).size)
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid LIKE 'TEST_SWEEP_%'", {}) { rs -> rs.getInt(1) }.first())
// 重放删除:安全返回 0,无副作用
val deleted2 = repo.deleteByFlids(flids.toSet())
assertEquals(0, deleted2)
}
@Test
fun `PG dialect - non-UTC JVM default timezone preserves exact Instant timestamps without drift`() {
val originalTz = TimeZone.getDefault()
try {
// 1. 设置 JVM 默认时区为东京 (UTC+09:00)
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo")))
val day = "2026-09-07"
val fixedInstant = Instant.parse("2026-09-07T08:30:15.123456Z")
repo.persistNextStates(
day,
snapshotStates(day, listOf("TEST_TZ_01" to mapOf("FLID" to "TEST_TZ_01"))),
snapshotReplace = true,
now = fixedInstant,
)
repo.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_TZ_01")), fixedInstant)
// 东京时区下回读
val genTokyo = repo.getGen(day)
assertNotNull(genTokyo)
assertEquals(fixedInstant, genTokyo!!.updatedAt)
// 2. 切换 JVM 默认时区为纽约 (UTC-05:00)
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("America/New_York")))
// 纽约时区下再次回读同一条记录
val genNy = repo.getGen(day)
assertNotNull(genNy)
// 断言:TIMESTAMPTZ 映射到 Instant 完全相等,绝无 JVM 时区偏移
assertEquals(fixedInstant, genNy!!.updatedAt)
val flightNy = ds.queryOne("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_TZ_01'", {}) { rs ->
rs.getInstant("created_at") to rs.getInstant("updated_at")
}
assertNotNull(flightNy)
assertEquals(fixedInstant, flightNy!!.first)
assertEquals(fixedInstant, flightNy.second)
} finally {
TimeZone.setDefault(originalTz)
}
}
@Test
fun `PG dialect - non-UTC database session timezone produces zero Instant drift across sessions`() {
val day = "2026-09-07"
val writeInstant = Instant.parse("2026-09-07T14:45:00.654321Z")
// 1. 在会话 1 中显式设置会话时区为 'Asia/Shanghai' (+08:00) 并写入数据
ds.connection.use { conn ->
conn.createStatement().use { it.execute("SET TIME ZONE 'Asia/Shanghai'") }
conn.prepareStatement(
"""
INSERT INTO flight_schd (flid, fday, remc, created_at, updated_at)
VALUES ('TEST_SESS_01', '2026-09-07', 'shanghai', ?, ?)
""".trimIndent(),
).use { ps ->
ps.setTimestamp(1, writeInstant.toSqlTimestamp())
ps.setTimestamp(2, writeInstant.toSqlTimestamp())
ps.executeUpdate()
}
}
// 2. 在会话 2 中显式设置会话时区为 'America/Chicago' (-05:00) 并回读数据
val instantChicago = ds.connection.use { conn ->
conn.createStatement().use { it.execute("SET TIME ZONE 'America/Chicago'") }
conn.prepareStatement("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_SESS_01'").use { ps ->
ps.executeQuery().use { rs ->
assertTrue(rs.next())
rs.getInstant("created_at")
}
}
}
// 3. 在会话 3 中显式设置会话时区为 'UTC' 并回读数据
val instantUtc = ds.connection.use { conn ->
conn.createStatement().use { it.execute("SET TIME ZONE 'UTC'") }
conn.prepareStatement("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_SESS_01'").use { ps ->
ps.executeQuery().use { rs ->
assertTrue(rs.next())
rs.getInstant("created_at")
}
}
}
// 断言:跨不同数据库会话时区(+08:00 vs -05:00 vs UTC),读取出的 Instant 与写入的 UTC Instant 完全一致!
assertEquals(writeInstant, instantChicago)
assertEquals(writeInstant, instantUtc)
assertEquals(instantChicago, instantUtc)
}
@Test
fun `PG dialect - persistNextStates roundtrip preserves collections source_seq and state_version`() {
val day = "2026-09-07"
val flid = "TEST_V2_ROUNDTRIP"
val gates = listOf(
mapOf("GTNO" to "1", "GATE" to "G28", "GTYP" to "D"),
mapOf("GTNO" to "3", "GATE" to "G33", "GTYP" to "I"),
mapOf("GTNO" to "5", "GATE" to "G23", "GTYP" to "D"),
)
val state = com.gzzn.omms.msgexchange.domain.flight.FlightNextState(
flid = flid,
scalars = mapOf("FLNO" to "CA888"),
collections = mapOf("GTDT" to gates),
stateVersion = 1L,
lastMessageId = "msg-dnld-001",
)
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
val roundtrip = repo.findNextStateByFlid(flid)!!
assertEquals(1L, roundtrip.stateVersion)
assertEquals("msg-dnld-001", roundtrip.lastMessageId)
// 明细表读回保序保源序号;未填属性以空串回填,按语义字段断言
val roundtripGates = roundtrip.collections["GTDT"]!!
assertEquals(3, roundtripGates.size)
gates.forEachIndexed { i, expected ->
val actual = roundtripGates[i]
assertEquals(expected["GTNO"], actual["GTNO"])
assertEquals(expected["GATE"], actual["GATE"])
assertEquals(expected["GTYP"], actual["GTYP"])
}
val replaced = state.copy(
collections = mapOf("GTDT" to listOf(mapOf("GTNO" to "2", "GATE" to "Z9"))),
stateVersion = 2L,
lastMessageId = "msg-flop-002",
)
repo.persistNextStates(null, listOf(replaced), snapshotReplace = false)
assertEquals("Z9", repo.findNextStateByFlid(flid)!!.collections["GTDT"]!![0]["GATE"])
assertEquals(2L, repo.findNextStateByFlid(flid)!!.stateVersion)
val detailRows = ds.query(
"SELECT ordinal, source_seq, gate FROM flight_gate WHERE flid = ? ORDER BY ordinal",
{ ps -> ps.setString(1, flid) },
) { rs -> Triple(rs.getInt("ordinal"), rs.getString("source_seq"), rs.getString("gate")) }
assertEquals(1, detailRows.size)
assertEquals(Triple(1, "2", "Z9"), detailRows[0])
}
@Test
fun `PG dialect - persistNextStates leaves all legacy collection storage columns null`() {
val day = "2026-09-07"
val flid = "TEST_V2_ALL_COLS"
val state = com.gzzn.omms.msgexchange.domain.flight.FlightNextState(
flid = flid,
scalars = mapOf("FLNO" to "MU123"),
collections = mapOf(
"GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "G1", "GTYP" to "D")),
"CKDT" to listOf(mapOf("CKNO" to "1", "CHKC" to "C1", "CTYP" to "Eco")),
"CLDT" to listOf(mapOf("CLNO" to "1", "BELT" to "B1", "BTYP" to "D")),
"DELY" to listOf(mapOf("DLNO" to "1", "CODE" to "01", "STRT" to "1200", "DURA" to "30")),
"ABTM" to listOf(mapOf("ASNO" to "1", "ABDG" to "B12", "ABOP" to "A", "AOTM" to "0730")),
"CHOT" to listOf(mapOf("CSNO" to "1", "CHID" to "ON", "CHTM" to "0800")),
"ROUT" to listOf(mapOf("RTNO" to "1", "APCD" to "CTU")),
),
stateVersion = 1L,
lastMessageId = "msg-dnld-002",
)
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
assertEquals("MU123", repo.findByFlid(flid)!!["FLNO"])
assertEquals(1, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
assertEquals(1, ds.query("SELECT COUNT(*) FROM flight_delay WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
}
@Test
fun `PG dialect - schd_gen stores last_message_id and rejects stale CAS without advancing`() {
val day = "2026-09-07"
// 初始提交:last_message_id = "m1",版本 1
val ok1 = repo.putGenIfVersion(
day, 0L,
FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_CAS_M"), lastMessageId = "m1"),
)
assertTrue(ok1)
assertEquals("m1", repo.getGen(day)!!.lastMessageId)
// 重放判定:以过期 expected=0 重试必须失败(同一消息的身份凭 getGen().lastMessageId 判定,不是靠版本相等)
val replayAttempt = repo.putGenIfVersion(
day, 0L,
FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_CAS_M"), lastMessageId = "m1"),
)
assertFalse(replayAttempt)
assertEquals(1L, repo.getGen(day)!!.version)
// 正常推进 expected=1 → 版本 2last_message_id 换为 "m2"
val ok2 = repo.putGenIfVersion(
day, 1L,
FlightSchdRepository.GenMeta(day, 2L, setOf("TEST_CAS_M"), lastMessageId = "m2"),
)
assertTrue(ok2)
val gen = repo.getGen(day)!!
assertEquals(2L, gen.version)
assertEquals("m2", gen.lastMessageId)
}
@Test
fun `PG dialect - flight_schd_display view projects first second resources and totals from detail tables`() {
val day = "2026-09-07"
val flid = "TEST_DISPLAY"
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
repo.persistNextStates(
day,
listOf(
engine.apply(
null,
engine.commandsFromFields(
flid,
mapOf(
"FLNO" to "CA display",
"GTDT" to """[{"GTNO":"1","GATE":"G28"},{"GTNO":"2","GATE":"G33"},{"GTNO":"3","GATE":"G23"}]""",
"CKDT" to """[{"CKNO":"1","CHKC":"01","CCLS":"Y"},{"CKNO":"2","CHKC":"17","CCLS":"F"}]""",
"CLDT" to """[{"CLNO":"1","BELT":"B01"}]""",
"DELY" to """[{"CODE":"YY","STRT":"07SEP261605","DURA":"0200"}]""",
),
),
"msg-display-1", bumpVersion = true,
),
),
snapshotReplace = true,
)
val row = ds.queryOne(
"""
SELECT flno, gate1, gate2, gate_total, chkc1, chkc2, checkin_total, belt1, belt_total, dely_code, dely_strt
FROM flight_schd_display WHERE flid = ?
""".trimIndent(),
{ ps -> ps.setString(1, flid) },
) { rs ->
listOf(
rs.getString("flno"), rs.getString("gate1"), rs.getString("gate2"), rs.getInt("gate_total"),
rs.getString("chkc1"), rs.getString("chkc2"), rs.getInt("checkin_total"),
rs.getString("belt1"), rs.getInt("belt_total"),
rs.getString("dely_code"), rs.getString("dely_strt"),
)
}
// 视图提供首个/第二个资源 + 总数;完整明细仍在明细表可查(三门不丢失)
assertEquals(
listOf(
"CA display", "G28", "G33", 3,
"01", "17", 2,
"B01", 1,
"YY", "07SEP261605",
),
row,
)
// 集合替换后视图随明细表同步(3 门 → 1 门)
repo.persistNextStates(
null,
listOf(
engine.apply(
repo.findNextStateByFlid(flid),
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"5","GATE":"Z1"}]""")),
"msg-display-2", bumpVersion = true,
),
),
snapshotReplace = false,
)
val after = ds.queryOne(
"SELECT gate1, gate2, gate_total FROM flight_schd_display WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> Triple(rs.getString("gate1"), rs.getString("gate2"), rs.getInt("gate_total")) }
assertEquals(Triple("Z1", null, 1), after)
}
@Test
fun `PG dialect - exception object explicit clear writes null columns and omits wire key`() {
val day = "2026-09-07"
val flid = "TEST_EXC_CLEAR"
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
// 1. 快照写入返航异常对象 → 主表前缀标量列
repo.persistNextStates(
day,
listOf(
engine.apply(
null,
engine.commandsFromFields(flid, mapOf("FRET" to """{"REID":"CA108","RSN":"diverted"}""")),
"msg-exc-1", bumpVersion = true,
),
),
snapshotReplace = true,
)
val stored1 = ds.queryOne(
"SELECT fret_reid, fret_rsn FROM flight_schd WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("fret_reid"), rs.getString("fret_rsn")) }
assertEquals(listOf("CA108", "diverted"), stored1)
// 2. 增量显式清除(FRET="null")→ 列置 NULL,读视图无 FRET 键(评审 F4 修复闭环)
repo.persistNextStates(
null,
listOf(
engine.apply(
repo.findNextStateByFlid(flid),
engine.commandsFromFields(flid, mapOf("FRET" to "null")),
"msg-exc-2", bumpVersion = true,
),
),
snapshotReplace = false,
)
val stored2 = ds.queryOne(
"SELECT fret_reid, fret_rsn FROM flight_schd WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("fret_reid"), rs.getString("fret_rsn")) }
assertEquals(listOf(null, null), stored2)
assertNull(repo.findByFlid(flid)!!["FRET"])
assertNull(repo.findNextStateByFlid(flid)!!.scalars["FRET"])
}
@Test
fun `PG dialect - readAfterWrite equals nextState across all structures and wire stays consistent`() {
val day = "2026-09-07"
val flid = "TEST_MATRIX"
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
val fields = mapOf(
"FLNO" to "CA_MATRIX",
"SODT" to "07SEP260800",
"GTDT" to """[{"GTNO":"1","GATE":"G28","GTYP":"D"},{"GTNO":"3","GATE":"G33"},{"GTNO":"3","GATE":"G23"}]""",
"CKDT" to """[{"CKNO":"1","CHKC":"01","CCLS":"Y"},{"CKNO":"2","CHKC":"01","CCLS":"F"}]""",
"CLDT" to """[{"CLNO":"1","BELT":"B01"},{"CLNO":"2","BELT":"B02"}]""",
"PSDT" to """[{"PSNO":"1","PSST":"S209"}]""",
"CHDT" to """[{"CHNO":"1","CHUT":"C1"}]""",
"DELY" to """[{"CODE":"YY","STRT":"07SEP261605","DURA":"0200","REMC":"delayed"},{"CODE":"WX","STRT":"07SEP261800","DURA":"0030"}]""",
"ABTM" to """[{"ASNO":"1","ABDG":"B01","ABOP":"A","AOTM":"07SEP261700"},{"ASNO":"2","ABDG":"B01","ABOP":"D","AOTM":"07SEP261900"}]""",
"CHOT" to """[{"CSNO":"1","CHID":"ON","CHTM":"07SEP261650"},{"CSNO":"2","CHID":"OFF","CHTM":"07SEP261920"}]""",
"ROUT" to """[{"RTNO":"1","APCD":"CTU"},{"RTNO":"2","APCD":"PEK","SCDT":"07SEP261100"}]""",
"FDIV" to """{"DDES":"PEK","DDIR":"TO"}""",
"SRVT" to """[{"SRTC":"WHEEL"}]""",
)
val state = engine.apply(null, engine.commandsFromFields(flid, fields), "msg-matrix-1", bumpVersion = true)
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
// 断言一:readAfterWrite == nextState(评审 F5 的持久化侧闭环)
val readBack = repo.findNextStateByFlid(flid)!!
assertEquals(state.stateVersion, readBack.stateVersion)
assertEquals(state.lastMessageId, readBack.lastMessageId)
assertEquals(state.scalars.keys, readBack.scalars.keys)
state.scalars.forEach { (k, v) -> assertJsonEquivalent(v, readBack.scalars[k], "scalar[$k]") }
assertEquals(state.collections.keys, readBack.collections.keys)
state.collections.forEach { (key, items) ->
val actual = readBack.collections[key]!!
assertEquals(items.size, actual.size, "collection[$key] row count")
items.forEachIndexed { i, item ->
item.forEach { (attr, v) -> assertEquals(v, actual[i][attr], "collection[$key][$i].$attr") }
}
}
// 断言二:wire(nextState) == wire(readAfterWrite)(出站载荷与库态同源,F5 的线格式侧闭环)
val wireBefore = mapper.readTree(com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson.toJson(state.toFlightFields()))
val wireAfter = mapper.readTree(com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson.toJson(repo.findByFlid(flid)!!))
assertEquals(wireBefore, wireAfter)
}
/** JSON 文本按解析树等价比较(键序无关);非 JSON 退化为字符串比较。 */
private fun assertJsonEquivalent(expected: String?, actual: String?, path: String) {
if (expected == null || actual == null) {
assertEquals(expected, actual, path)
return
}
val e = runCatching { mapper.readTree(expected) }.getOrNull()
val a = runCatching { mapper.readTree(actual) }.getOrNull()
if (e != null && a != null) assertEquals(e, a, path) else assertEquals(expected, actual, path)
}
}
@@ -3,40 +3,43 @@ 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
import java.sql.DriverManager
/**
* P2-1 验证测试:Flyway 迁移引擎在真实 PostgreSQL 上的端到端自动迁移验证。
* 断言:
* 1. Flyway 自动执行 V1.0.0__own_pg_pipeline.sql 与 V1.1.0__flight_schd.sql
* 2. 真实落库 flyway_schema_history 元数据表,且 success = true
* 3. 运营航班表 FLIGHT_SCHD 与计划代 SCHD_GEN 结构完整就绪。
* Flyway 迁移引擎端到端验证(docs/flight-state.md §3.2 表职责基线):
* 1. V1__flight_state_baseline.sql 在真实 PostgreSQL 上自动迁移成功;
* 2. flyway_schema_history 落库且 success = true
* 3. 决策层/管道层/留痕层全表就绪;PIPELINE_LOCK 单行种子就位。
*/
class FlywayMigrationTest {
private val expectedTables = setOf(
"pipeline_lock", "proc_state", "flight_schd",
"flight_gate", "flight_checkin", "flight_belt", "flight_stand_plan", "flight_chute",
"flight_delay", "flight_bridge_op", "flight_chock_op", "flight_route_point",
"msg_event", "req_track", "backfill_todo", "schd_snap_log",
)
@Test
fun `Flyway automatically migrates V1_0_0 and V1_1_0 onto real PostgreSQL`() {
fun `Flyway migrates flight-state baseline onto real PostgreSQL`() {
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
val url = PgTestSupport.jdbcUrl
val user = PgTestSupport.user
val pass = PgTestSupport.password
val flyway = Flyway.configure()
val migrateResult = Flyway.configure()
.dataSource(url, user, pass)
.locations("classpath:db/migration")
.load()
val migrateResult = flyway.migrate()
.migrate()
assertTrue(migrateResult.success, "Flyway migration must succeed")
// 验证 flyway_schema_history 元数据表与执行记录
DriverManager.getConnection(url, user, pass).use { conn ->
conn.createStatement().use { stmt ->
// 迁移记录:单基线 V1
stmt.executeQuery(
"SELECT version, script, success FROM flyway_schema_history ORDER BY installed_rank ASC",
).use { rs ->
@@ -44,115 +47,38 @@ class FlywayMigrationTest {
while (rs.next()) {
records.add(Triple(rs.getString("version"), rs.getString("script"), rs.getBoolean("success")))
}
assertTrue(records.size >= 2, "At least 2 migrations must be recorded in flyway_schema_history")
assertEquals("1.0.0", records[0].first)
assertEquals("V1.0.0__own_pg_pipeline.sql", records[0].second)
assertTrue(records.isNotEmpty(), "flyway_schema_history must record migrations")
assertEquals("1", records[0].first)
assertEquals("V1__flight_state_baseline.sql", records[0].second)
assertTrue(records[0].third)
assertEquals("1.1.0", records[1].first)
assertEquals("V1.1.0__flight_schd.sql", records[1].second)
assertTrue(records[1].third)
}
// 验证 FLIGHT_SCHD 与 SCHD_GEN 存在
// §3.2 全表就绪
stmt.executeQuery(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('flight_schd', 'schd_gen')",
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'",
).use { rs ->
val tables = mutableSetOf<String>()
while (rs.next()) {
tables.add(rs.getString("table_name"))
}
assertTrue(tables.contains("flight_schd"))
assertTrue(tables.contains("schd_gen"))
while (rs.next()) tables.add(rs.getString("table_name"))
val missing = expectedTables - tables
assertTrue(missing.isEmpty(), "missing tables: $missing")
}
// 身份不变量基础结构:FLIGHT_SCHD 主键 + STATE 列
stmt.executeQuery(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'flight_schd' " +
"AND column_name IN ('flid', 'operation_day', 'state', 'state_version', 'last_msg_id')",
).use { rs ->
val cols = mutableSetOf<String>()
while (rs.next()) cols.add(rs.getString("column_name"))
assertEquals(setOf("flid", "operation_day", "state", "state_version", "last_msg_id"), cols)
}
// PIPELINE_LOCK 单行种子(§3.2
stmt.executeQuery("SELECT count(*) FROM pipeline_lock WHERE lock_id = 1").use { rs ->
assertTrue(rs.next())
assertEquals(1, rs.getInt(1))
}
}
}
}
/**
* P2-4:带数据的 V1.1.0 → 最新版本升级链验证(隔离 schema,不污染 public)。
* V1.2.0 删除 *_TXT 集合列是「已知损失边界」(评审 F6):标量列与结构完整存活,
* 集合文本不可恢复——升级前必须完整导出或重放(docs/flight-state.md)。
*/
@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") }
}
}
}
@@ -23,13 +23,15 @@ class ReplayServiceTest {
rows[id] = ProcState(id, status, identityKey = "k$id", attempts = 3, errorClass = ec, lastError = "x")
}
override fun insert(cminmsgsId: Long, state: ProcStatus) = Unit
override fun exists(cminmsgsId: Long): Boolean = rows.containsKey(cminmsgsId)
override fun insert(msgId: Long, state: ProcStatus) = Unit
override fun exists(msgId: Long): Boolean = rows.containsKey(msgId)
override fun find(msgId: Long): ProcState? = rows[msgId]
override fun findSuccessTerminal(msgId: Long): Boolean = rows[msgId]?.state == ProcStatus.SUCCEEDED
override fun headUnfinished(): ProcState? = null
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean = true
override fun tryBindIdentity(msgId: Long, identityKey: String): Boolean = true
override fun ownerOfIdentity(identityKey: String): Long? = null
override fun update(
cminmsgsId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
msgId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
errorClass: ErrorClass?, lastError: String?,
) = Unit
@@ -56,10 +56,10 @@ class BackfillSweepJobTest {
assertEquals(BackfillSweepJob.SweepOutcome(inspected = 1, succeeded = 0, failed = 1), outcome)
assertEquals(1, todo.count())
assertEquals(1, todo.tasks().single().attempts)
assertEquals(1, todo.tasks.values.single().attempts)
assertEquals("mysql-down", todo.lastErrorOf(901L))
// 一次失败后 30s 内不再到期(退避生效)
assertEquals(emptyList<BackfillTodoRepository.BackfillTask>(), todo.findDue(t0.plusSeconds(29)))
assertEquals(0, todo.findDue(t0.plusSeconds(29)).size)
assertEquals(1, todo.findDue(t0.plusSeconds(30)).size)
}
@@ -1,168 +1,103 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import com.gzzn.omms.msgexchange.support.seedSnapshot
import org.junit.jupiter.api.AfterEach
import com.gzzn.omms.msgexchange.config.HistoryProps
import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.flight.FlightState
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Instant
/**
* ACM2-28 FS6 历史清场测试
* 覆盖 ES 全成功、部分成功、全部失败、ES 成功后 PG 删除前崩溃、PG 删除重放五种场景
* 断言失败项不丢、成功项最终删除、重复执行无副作用
* 历史归档与物理清除(docs/flight-state.md §8.2,顺序不可颠倒)
* 历史存储未接通必须删 0 条;先归档确认再物理清除
* 未经 FDEL 的航班在清除前补发删除事件
*/
class HistorySweepJobTest {
private lateinit var flightSchd: StubFlightSchd
private lateinit var sweepJob: HistorySweepJob
private val now: Instant = Instant.parse("2026-09-09T00:00:00Z")
@BeforeEach
fun setUp() {
flightSchd = StubFlightSchd()
sweepJob = HistorySweepJob(flightSchd)
HistorySweepJob.historyPicker = null
HistorySweepJob.esArchiver = null
HistorySweepJob.cutoffProvider = null
}
@AfterEach
fun tearDown() {
HistorySweepJob.historyPicker = null
HistorySweepJob.esArchiver = null
HistorySweepJob.cutoffProvider = null
}
// 1. 场景一:ES 全成功
@Test
fun `Scenario 1 - ES All Success removes all confirmed flights and cleans old gens`() {
val flights = listOf(
"F1" to mapOf("FLID" to "F1"),
"F2" to mapOf("FLID" to "F2"),
"F3" to mapOf("FLID" to "F3"),
private fun seededFlight(flid: String, deleted: Boolean, idleDays: Long): StubFlightState {
val f = StubFlightState()
f.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
flid, java.time.LocalDate.of(2026, 9, 1), if (deleted) FlightState.DELETED else FlightState.ACTIVE,
1, mapOf("FLNO" to "CA001"), emptyMap(),
),
msgId = 1,
now = now.minusSeconds(idleDays * 86400),
)
seedSnapshot(flightSchd, "2026-09-01", flights)
flightSchd.putGenIfVersion("2026-09-01", 0L, FlightSchdRepository.GenMeta("2026-09-01", 1L, setOf("F1", "F2", "F3")))
// 模拟判史:F1, F2, F3 全部为历史候选
HistorySweepJob.historyPicker = { it }
// 模拟 ES 写入:全部成功返回
HistorySweepJob.esArchiver = { it.keys }
// 模拟历史代清理:清理 2026-09-02 之前的代
HistorySweepJob.cutoffProvider = { "2026-09-02" }
sweepJob.run()
// 断言:PG 中 F1, F2, F3 全部被删除
assertNull(flightSchd.findByFlid("F1"))
assertNull(flightSchd.findByFlid("F2"))
assertNull(flightSchd.findByFlid("F3"))
// 断言:过期代元数据被清理
assertNull(flightSchd.getGen("2026-09-01"))
return f
}
// 2. 场景二:ES 部分成功
@Test
fun `Scenario 2 - ES Partial Success deletes only successful flights and keeps failed ones`() {
val flights = listOf(
"F1" to mapOf("FLID" to "F1"),
"F2" to mapOf("FLID" to "F2"),
"F3" to mapOf("FLID" to "F3"),
)
seedSnapshot(flightSchd, "2026-09-01", flights)
private class RecordingHistoryStore : HistorySweepJob.HistoryStore {
val archivedBatches = mutableListOf<List<HistoryCandidate>>()
val confirmed = mutableSetOf<String>()
var fail = false
HistorySweepJob.historyPicker = { it }
// 模拟 ES 写入:F1 和 F2 成功,F3 失败
HistorySweepJob.esArchiver = { setOf("F1", "F2") }
sweepJob.run()
// 断言:成功项最终删除
assertNull(flightSchd.findByFlid("F1"))
assertNull(flightSchd.findByFlid("F2"))
// 断言:失败项 F3 绝不丢失,继续保留在 PG 中供下次重试
assertNotNull(flightSchd.findByFlid("F3"))
}
// 3. 场景三:ES 全部失败
@Test
fun `Scenario 3 - ES All Failure deletes nothing and keeps all candidates in PG`() {
val flights = listOf(
"F1" to mapOf("FLID" to "F1"),
"F2" to mapOf("FLID" to "F2"),
)
seedSnapshot(flightSchd, "2026-09-01", flights)
HistorySweepJob.historyPicker = { it }
// 模拟 ES 全部写入失败
HistorySweepJob.esArchiver = { emptySet() }
sweepJob.run()
// 断言:PG 未执行任何删除,全部候选均安全保留
assertNotNull(flightSchd.findByFlid("F1"))
assertNotNull(flightSchd.findByFlid("F2"))
}
// 4. 场景四:ES 成功后 PG 删除前崩溃重放
@Test
fun `Scenario 4 - Crash after ES success before PG delete recovers idempotently on next run`() {
val flights = listOf(
"F1" to mapOf("FLID" to "F1"),
)
seedSnapshot(flightSchd, "2026-09-01", flights)
// 第一次运行模拟:ES 写入成功,但在调用 deleteByFlids 前崩溃(PG 依然保留 F1
val esUpserted = mutableSetOf<String>()
var crashed = false
HistorySweepJob.historyPicker = { it }
HistorySweepJob.esArchiver = {
esUpserted.addAll(it.keys)
if (!crashed) {
crashed = true
throw RuntimeException("crash-before-pg-delete")
}
it.keys
override fun archive(candidates: List<HistoryCandidate>): Set<String> {
archivedBatches.add(candidates)
if (fail) return emptySet()
return candidates.map { it.flid }.toSet().intersect(confirmed.ifEmpty { candidates.map { it.flid }.toSet() })
}
try {
sweepJob.run()
} catch (_: RuntimeException) {
// 崩溃发生
}
// 崩溃后断言:ES 已有数据,但 PG 中 F1 仍然存在作为可恢复事实
assertTrue(esUpserted.contains("F1"))
assertNotNull(flightSchd.findByFlid("F1"))
// 下一次作业重试:
sweepJob.run()
// 断言:重试成功,PG 中 F1 最终完成删除
assertNull(flightSchd.findByFlid("F1"))
}
// 5. 场景五:PG 删除重复执行(无副作用与幂等性)
@Test
fun `Scenario 5 - Replaying deleteByFlids is completely idempotent with no side effects`() {
val flights = listOf("F1" to mapOf("FLID" to "F1"))
seedSnapshot(flightSchd, "2026-09-01", flights)
fun `history store not connected must delete zero rows`() {
val f = seededFlight("F1", deleted = true, idleDays = 30)
val events = StubMsgEvents()
val job = HistorySweepJob(f, events, HistoryProps().apply { historyStoreEnabled = false })
// 首次删除
val deletedCount1 = flightSchd.deleteByFlids(setOf("F1"))
assertEquals(1, deletedCount1)
assertNull(flightSchd.findByFlid("F1"))
val outcome = job.run(now)
// 重复执行删除(重放)
val deletedCount2 = flightSchd.deleteByFlids(setOf("F1"))
// 断言:允许重放时影响行数为 0,不抛出异常,无任何副作用
assertEquals(0, deletedCount2)
assertNull(flightSchd.findByFlid("F1"))
assertEquals(0, outcome.purged) // §8.2 红线:先删历史再补当前态绝不允许
assertTrue(f.findMainRow("F1") != null)
assertEquals(0, events.rows.size)
}
@Test
fun `archive success precedes physical purge and confirmed rows only`() {
val f = seededFlight("F1", deleted = true, idleDays = 30)
f.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"F2", java.time.LocalDate.of(2026, 9, 1), FlightState.DELETED, 1, mapOf("FLNO" to "CA002"), emptyMap(),
),
msgId = 2, now = now.minusSeconds(30 * 86400),
)
val events = StubMsgEvents()
val store = RecordingHistoryStore().apply { confirmed.add("F1") } // 只有 F1 归档确认成功
val job = HistorySweepJob(
f, events, HistoryProps().apply { historyStoreEnabled = true }, historyStore = store,
)
val outcome = job.run(now)
assertEquals(2, outcome.selected)
assertEquals(1, outcome.archived)
assertEquals(1, outcome.purged)
assertEquals(null, f.findMainRow("F1")) // §8.2 步骤 3:确认成功 → 物理删除
assertTrue(f.findMainRow("F2") != null) // 失败或不明确的保留重试(步骤 5)
assertEquals(0, events.rows.size) // DELETED 航班清除不再发业务删除事件
}
@Test
fun `never-fdel lifecycle purge emits tombstone before deletion`() {
val f = seededFlight("F3", deleted = false, idleDays = 30) // ACTIVE 且超兜底窗(§8.1
val events = StubMsgEvents()
val store = RecordingHistoryStore()
val job = HistorySweepJob(
f, events, HistoryProps().apply { historyStoreEnabled = true }, historyStore = store,
)
job.run(now)
// §7.3/§8.2 步骤 4:未经 FDEL 的航班,清除前补发一次删除事件
val tombstone = events.rows.values.single { it.eventType == EventType.TOMBSTONE }
assertEquals("F3", tombstone.partitionKey)
assertTrue(tombstone.payloadJson.contains("\"deleted\":true"))
assertEquals(null, f.findMainRow("F3"))
}
}
@@ -0,0 +1,172 @@
package com.gzzn.omms.msgexchange.processing
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.config.OperationDayProps
import com.gzzn.omms.msgexchange.codec.FlopPayload
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.domain.flight.FlightState
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
import com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineLock
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.LocalDate
/**
* FDEL(§6.2+ ADFT(§6.3/§2.1)不变量:
* ACTIVE→DELETED 发布一次 tombstone 且明细保留;重复/迟到 FDEL 幂等不推进版本;
* ADFT 重激活恢复 ACTIVE;新实例建立;缺失标量不误删(§2.1 保守语义)。
*/
class FdelAndAdftProcessorTest {
private val msgId = 7L
private fun head() = ProcState(msgId, ProcStatus.PENDING)
private fun msg() = DecodedMessage(
meta = MetaFields("AODB", "FDEL", "", 9L, 1L),
kind = MsgKind.Fdel,
rawXml = "<MSG/>",
body = FlopPayload("121"),
)
private fun flights(): StubFlightState {
val f = StubFlightState()
val outcome = f.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"121", LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 4,
mapOf("FLNO" to "CA001"), emptyMap(),
),
msgId = 1,
now = java.time.Instant.now(),
)
check(outcome != PersistOutcome.DAY_GUARD_VIOLATION)
return f
}
@Test
fun `active flight deletion publishes single tombstone and keeps details`() {
val f = flights()
val events = StubMsgEvents()
val todo = StubBackfillTodo()
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, todo, ObjectMapper())
.apply(head(), msg(), FlopPayload("121"))
assertEquals(ApplyResult.Succeeded, result)
assertEquals(FlightState.DELETED, f.findMainRow("121")!!.state)
assertEquals(5L, f.findMainRow("121")!!.stateVersion)
val tombstones = events.rows.values.filter { it.eventType == EventType.TOMBSTONE }
assertEquals(1, tombstones.size)
assertEquals(Targets.KAFKA_SCHD, tombstones.single().target)
assertTrue(tombstones.single().payloadJson.contains("\"deleted\":true"))
assertEquals(1, todo.count())
}
@Test
fun `repeated FDEL is idempotent without version bump or duplicate event`() {
val f = flights()
val events = StubMsgEvents()
val proc = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, null, ObjectMapper())
proc.apply(head(), msg(), FlopPayload("121"))
val version = f.findMainRow("121")!!.stateVersion
val result = proc.apply(head(), msg(), FlopPayload("121"))
assertEquals(ApplyResult.Succeeded, result) // §6.2 步骤 3:幂等成功
assertEquals(version, f.findMainRow("121")!!.stateVersion)
assertEquals(1, events.rows.values.count { it.eventType == EventType.TOMBSTONE })
}
@Test
fun `FDEL of unknown flight is idempotent success`() {
val f = StubFlightState()
val events = StubMsgEvents()
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, null, ObjectMapper())
.apply(head(), msg(), FlopPayload("999"))
assertEquals(ApplyResult.Succeeded, result) // §6.2 步骤 4:迟到不报错
assertEquals(0, events.rows.size)
}
@Test
fun `ADFT revives deleted flight and sets provided scalars`() {
val f = flights()
f.markDeleted("121", msgId = 2, now = java.time.Instant.now())
val events = StubMsgEvents()
val adft = AdftProcessor(
StubPipelineTx(), StubPipelineLock(), f, events, null,
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
)
val record = com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord(
"121", mapOf("SODT" to "15DEC261723", "FLNO" to "CA002"),
)
val result = adft.apply(head(), msg(), record)
assertEquals(ApplyResult.Succeeded, result)
val main = f.findMainRow("121")!!
assertEquals(FlightState.ACTIVE, main.state) // §6.3 生命周期重激活
assertEquals(LocalDate.of(2026, 12, 15), main.operationDay)
assertEquals("CA002", f.loadFullSnapshot("121")!!.scalars["FLNO"])
}
@Test
fun `ADFT of unknown flight creates new instance with operation day from SODT`() {
val f = StubFlightState()
val events = StubMsgEvents()
val adft = AdftProcessor(
StubPipelineTx(), StubPipelineLock(), f, events, null,
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
)
val result = adft.apply(
head(), msg(),
com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord("555", mapOf("SODT" to "20DEC261723")),
)
assertEquals(ApplyResult.Succeeded, result)
val main = f.findMainRow("555")!!
assertEquals(FlightState.ACTIVE, main.state)
assertEquals(LocalDate.of(2026, 12, 20), main.operationDay) // §2.1:含 SODT 直接计算
}
@Test
fun `ADFT merge does not clear absent scalars`() {
val f = StubFlightState()
f.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"555", LocalDate.of(2026, 12, 20), FlightState.ACTIVE, 2,
mapOf("FLNO" to "XX100", "REMC" to "keep-me"), emptyMap(),
),
msgId = 1, now = java.time.Instant.now(),
)
val adft = AdftProcessor(
StubPipelineTx(), StubPipelineLock(), f, StubMsgEvents(), null,
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
)
adft.apply(head(), msg(), com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord("555", mapOf("FLNO" to "XX200")))
val scalars = f.loadFullSnapshot("555")!!.scalars
assertEquals("XX200", scalars["FLNO"])
assertEquals("keep-me", scalars["REMC"]) // §2.1 保守 Set-only:缺失不 Clear
}
@Suppress("unused")
private fun unused() {
MsgEvent(target = "x", partitionKey = "y", payloadJson = "")
}
}
@@ -1,355 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.NotifyPayload
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.SchdPush
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
import com.gzzn.omms.msgexchange.support.seedIncremental
import com.gzzn.omms.msgexchange.support.seedSnapshot
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Instant
import java.time.ZoneId
import java.util.TimeZone
/**
* U09 / U29 不变量门禁测试(ACM2-28 FS6 核心验收门槛):
* 1. I2 不变量:航班状态变更、MSG_EVENT 插入、PROC_STATE 终态在单事务原子提交;失败整体回滚重放;
* 2. 门槛 1(崩溃幂等无自增):重放相同快照报文后 SCHD_GEN.VERSION 绝不发生二次自增;
* 3. 门槛 2(CAS 防并发):伪造版本过期模拟并发快照,准确拦截 CAS 冲突并转入 FAILED(INFRA) 与退避;
* 4. 门槛 3ADFT 存活保障):增量 ADFT 航班在后续 DNLD 跨代替换后天然存活、不被差删;跨代迁移保护;
* 5. UTC 方言测试:非 UTC JVM 时区下读写与幂等判据无漂移。
*/
class FlightSchdInvariantTest {
private lateinit var flightSchd: StubFlightSchd
private lateinit var procState: StubProcState
private lateinit var msgEvents: StubMsgEvents
private lateinit var reqTrack: StubReqTrack
private lateinit var txManager: StubPipelineTransactionManager
private lateinit var inbox: FakeInbox
private lateinit var clock: MutableClock
private lateinit var scheduler: FailureScheduler
private lateinit var procFailure: ProcFailure
private lateinit var props: PipelineProps
class FakeInbox : CminmsgInboxRepository {
val raws = mutableMapOf<Long, String>()
val backfilled = mutableListOf<Long>()
override fun insertRaw(rawXml: String): Long = 1L
override fun rawOf(cminmsgsId: Long): String? = raws[cminmsgsId]
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
backfilled.add(cminmsgsId)
}
}
class SimpleCodec(private val decodedMsg: DecodedMessage) : XmlCodec {
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(decodedMsg)
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
}
@BeforeEach
fun setUp() {
flightSchd = StubFlightSchd()
procState = StubProcState()
msgEvents = StubMsgEvents()
reqTrack = StubReqTrack()
txManager = StubPipelineTransactionManager()
inbox = FakeInbox()
clock = MutableClock(MutableClock.BASE)
props = PipelineProps()
scheduler = FailureScheduler(props, clock)
procFailure = ProcFailure(procState, scheduler)
SnapshotFlow.StageResult.parser = null
}
@AfterEach
fun tearDown() {
SnapshotFlow.StageResult.parser = null
}
// =========================================================================
// I2 不变量测试:普通报文事务 2 扩展(变更 + 事件 + SUCCEEDED 原子提交)
// =========================================================================
@Test
fun `I2 - MessageProcessor atomically commits flightChanges, msgEvents, and SUCCEEDED`() {
val headId = 101L
inbox.raws[headId] = "<MSG><FLID>F101</FLID></MSG>"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "FLOP", "DELY", 1L, 20260907120000L)
val decoded = DecodedMessage(meta, MsgKind.Flop("DELY"), "<MSG><FLID>F101</FLID></MSG>")
val codecHolder = CodecHolder(SimpleCodec(decoded))
val handler = object : Handler {
override val kind: MsgKind = MsgKind.Flop("DELY")
override fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision {
return Decision(
flightChanges = listOf(FlightChange("F101", mapOf("FLID" to "F101", "STAT" to "DELAYED"))),
msgNotifies = listOf(NotifyPayload("""{"flid":"F101","event":"DELAY"}""")),
schdPush = listOf(SchdPush("F101", """{"FLID":"F101","STAT":"DELAYED"}""")),
)
}
}
val handlerHolder = HandlerHolder(HandlerRegistry(listOf(handler)))
val snapshotFlow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val processor = MessageProcessor(
inbox, procState, msgEvents, codecHolder, handlerHolder,
flightSchd, snapshotFlow, procFailure, props, txManager,
)
val head = procState.headUnfinished()!!
processor.processOne(head)
// 断言:FLIGHT_SCHD 有更新(字段列 DELAYED 已落库)
val fields = flightSchd.findByFlid("F101")
assertNotNull(fields)
assertEquals("DELAYED", fields!!["STAT"])
// 断言:MSG_EVENT 写入了 KAFKA_MSG 和 KAFKA_SCHD 两条事件
val evtMsg = msgEvents.headUnsent(Targets.KAFKA_MSG)
assertNotNull(evtMsg)
val evtSchd = msgEvents.headUnsent(Targets.KAFKA_SCHD)
assertNotNull(evtSchd)
// 断言:伴生状态更新为 SUCCEEDED
val currentHead = procState.headUnfinished()
assertNull(currentHead) // 无未完成队头,说明 101 已终态
// 断言:共享信箱 backfill 成功调用
assertTrue(inbox.backfilled.contains(headId))
}
// =========================================================================
// FS6 门槛 1:崩溃幂等无自增(重放相同快照报文,SCHD_GEN.VERSION 绝不发生二次自增)
// =========================================================================
@Test
fun `Threshold 1 - Crash idempotency without version re-increment`() {
val headId = 201L
val day = "2026-09-07"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 1L, 20260907030000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
// 配置 staging 解析模拟产出 2 条航班
val flights = listOf(
"FL_01" to mapOf("FLID" to "FL_01", "FLNO" to "CA1234"),
"FL_02" to mapOf("FLID" to "FL_02", "FLNO" to "MU5678"),
)
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, flights) }
// 模拟快照单事务内崩溃注入(例如在提交前崩溃回滚)
var crashInjected = true
val rollbackTxManager = object : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T {
if (crashInjected) {
try {
block()
} finally {
// 事务回滚:清除未提交修改并恢复状态
flightSchd.clear()
procState.update(headId, ProcStatus.PENDING)
}
throw RuntimeException("crash-before-pg-commit")
}
return block()
}
}
val snapshotFlowCrashing = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, rollbackTxManager, inbox)
// 首次运行:事务内崩溃注入
val head1 = procState.headUnfinished()!!
try {
snapshotFlowCrashing.publishSnapshot(head1, decoded)
} catch (_: RuntimeException) {
// 崩溃发生
}
// 崩溃后断言:由于 PG 单事务回滚,SCHD_GEN 不存在半成品中间态,版本未推进
assertNull(flightSchd.getGen(day))
// 重放相同报文(正常完成)
crashInjected = false
val snapshotFlowNormal = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val headReplay = procState.headUnfinished()!!
snapshotFlowNormal.publishSnapshot(headReplay, decoded)
// 门槛 1 断言:版本绝不发生二次自增,版本号精确为 1L(根除旧 Redis 两阶段二次自增缺陷)
val genAfterReplay = flightSchd.getGen(day)
assertNotNull(genAfterReplay)
assertEquals(1L, genAfterReplay!!.version)
assertEquals(setOf("FL_01", "FL_02"), genAfterReplay.flids)
}
// =========================================================================
// FS6 门槛 2:CAS 防并发(伪造版本过期模拟并发快照,拦截 CAS 冲突并转入 FAILED(INFRA)
// =========================================================================
@Test
fun `Threshold 2 - CAS prevents concurrency and enters FAILED INFRA`() {
val headId = 202L
val day = "2026-09-07"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 2L, 20260907033000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
val flights = listOf("FL_01" to mapOf("FLID" to "FL_01"))
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, flights) }
// 先预置日代版本为 5L(模拟另一并发实例已经推进了版本)
flightSchd.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 5L, setOf("FL_OLD")))
// 构造一个在读取版本后版本被篡改的场景(模拟读到 5L 后,外部并发变成了 6L)
val mockFlightSchd = object : FlightSchdRepository by flightSchd {
override fun getGen(day: String): FlightSchdRepository.GenMeta? {
// 模拟读取时返回版本 5L
return FlightSchdRepository.GenMeta(day, 5L, setOf("FL_OLD"))
}
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
// 模拟 CAS 校验失败(数据库已被并发推进,expected 5 已过期)
return false
}
}
val snapshotFlow = SnapshotFlow(procState, mockFlightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val head = procState.headUnfinished()!!
snapshotFlow.publishSnapshot(head, decoded)
// 门槛 2 断言:CAS 冲突被拦截,状态转换为 FAILED(INFRA),带有退避与告警错误信息
val failedHead = procState.headUnfinished()!!
assertEquals(ProcStatus.FAILED, failedHead.state)
assertEquals(ErrorClass.INFRA, failedHead.errorClass)
assertTrue(failedHead.lastError?.contains("gen-cas-conflict") == true)
assertNotNull(failedHead.nextAttemptAt)
}
// =========================================================================
// FS6 门槛 3ADFT 存活保障(增量 ADFT 航班在后续 DNLD 跨代替换后天然存活、不被差删)
// =========================================================================
@Test
fun `Threshold 3 - ADFT flights survive subsequent DNLD replacement`() {
val day = "2026-09-07"
// 1. 增量更新写入一条临时加飞航班 ADFT(FDAY 为 NULL
val adftChange = FlightChange(flid = "ADFT_888", fields = mapOf("FLID" to "ADFT_888", "FLTY" to "ADFT"))
seedIncremental(flightSchd, listOf(adftChange))
// 2. 写入旧代的一条定期计划航班 REG_OLDFDAY 为 2026-09-07
seedSnapshot(flightSchd, day, listOf("REG_OLD" to mapOf("FLID" to "REG_OLD", "FLTY" to "REG")))
flightSchd.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("REG_OLD")))
// 3. 执行下一轮快照 DNLD,新代仅包含 REG_NEW(REG_OLD 不在新代中,属于待删差集;ADFT 也不在新代中)
val headId = 203L
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 3L, 20260907040000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
val newFlights = listOf("REG_NEW" to mapOf("FLID" to "REG_NEW", "FLTY" to "REG"))
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, newFlights) }
val snapshotFlow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val head = procState.headUnfinished()!!
snapshotFlow.publishSnapshot(head, decoded)
// 门槛 3 核心断言:
// ① 旧代航班 REG_OLD 被按代差删清除
assertNull(flightSchd.findByFlid("REG_OLD"))
// ② 新代航班 REG_NEW 成功写入
assertNotNull(flightSchd.findByFlid("REG_NEW"))
// ③ 增量 ADFT_888 航班由于 FDAY=NULL 天然存活、绝不被误删!
val adftRecord = flightSchd.findByFlid("ADFT_888")
assertNotNull(adftRecord)
assertEquals("ADFT_888", adftRecord!!["FLID"])
}
@Test
fun `Threshold 3 - Migrated flight across days is protected from diff deletion of old day`() {
// 场景:航班 FL_MIG 原属 2026-09-07,后来迁移到了 2026-09-08FDAY 更新为 09-08
val dayOld = "2026-09-07"
val dayNew = "2026-09-08"
// 初始属旧代
seedSnapshot(flightSchd, dayOld, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "07")))
flightSchd.putGenIfVersion(dayOld, 0L, FlightSchdRepository.GenMeta(dayOld, 1L, setOf("FL_MIG")))
// 随后 09-08 快照写入将 FDAY 更新为 2026-09-08
seedSnapshot(flightSchd, dayNew, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "08")))
// 此时 09-07 再次执行差删(差集中包含 FL_MIG)
val deleted = flightSchd.deleteDiffByDay(dayOld, listOf("FL_MIG"))
// 断言:FL_MIG 虽在差集,但由于 FDAY 已迁移至 09-08,受到域化差删保护,删除数为 0,记录依然存活!
assertEquals(0, deleted)
assertNotNull(flightSchd.findByFlid("FL_MIG"))
}
// =========================================================================
// UTC 时区规范测试:非 UTC JVM 默认时区与会话下写入读取无漂移
// =========================================================================
@Test
fun `UTC Dialect - Timestamps and instant evaluations are immune to JVM timezone drift`() {
val originalTz = TimeZone.getDefault()
try {
// 切换 JVM 默认时区为非 UTC(东京 +09:00 与 纽约 -05:00
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo")))
val now = Instant.parse("2026-09-07T08:00:00.123456Z")
seedSnapshot(flightSchd, "2026-09-07", listOf("TZ_01" to mapOf("FLID" to "TZ_01")), now)
flightSchd.putGenIfVersion("2026-09-07", 0L, FlightSchdRepository.GenMeta("2026-09-07", 1L, setOf("TZ_01")), now)
val gen = flightSchd.getGen("2026-09-07")
assertNotNull(gen)
assertEquals(now, gen!!.updatedAt)
// 再次切换到西五区
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("America/New_York")))
val genNy = flightSchd.getGen("2026-09-07")
assertNotNull(genNy)
assertEquals(now, genNy!!.updatedAt) // 绝无时区漂移
} finally {
TimeZone.setDefault(originalTz)
}
}
}
@@ -1,82 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
import com.gzzn.omms.msgexchange.processing.handlers.GtdtHandler
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/** P2-0-1GTDT 报文端到端 SUCCEEDED + 三门读回(真实 XmlCodec + GtdtHandler)。 */
class GtdtPipelineTest {
@Test
fun `GTDT XML end-to-end SUCCEEDED with three gates persisted`() {
val raw = """
<?xml version="1.0" encoding="UTF-8"?>
<MSG>
<META>
<SNDR>AODB</SNDR><SEQN>9001</SEQN><DTTM>20260908120000</DTTM>
<TYPE>FLOP</TYPE><STYP>GTDT</STYP>
</META>
<FLOP>
<FLID>FL_GTDT_E2E</FLID>
<GTDT GTNO="1"><GATE>A1</GATE><GTYP>D</GTYP></GTDT>
<GTDT GTNO="3"><GATE>B2</GATE><GTYP>I</GTYP></GTDT>
<GTDT GTNO="7"><GATE>C3</GATE><GTYP>D</GTYP></GTDT>
</FLOP>
</MSG>
""".trimIndent()
val procState = StubProcState()
val flightSchd = StubFlightSchd()
val msgEvents = StubMsgEvents()
val reqTrack = StubReqTrack()
val txManager = object : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
val inbox = object : CminmsgInboxRepository {
override fun insertRaw(rawXml: String) = 1L
override fun rawOf(cminmsgsId: Long) = raw
override fun pollUnprocessed(afterId: Long, limit: Int) = emptyList<Long>()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
}
val clock = MutableClock(MutableClock.BASE)
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), clock))
val codec = JacksonXmlCodec()
val handler = GtdtHandler()
val snapshot = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val processor = MessageProcessor(
inbox, procState, msgEvents,
CodecHolder(codec),
HandlerHolder(HandlerRegistry(listOf(handler))),
flightSchd, snapshot, procFailure, PipelineProps(), txManager,
)
val headId = 42L
procState.insert(headId, ProcStatus.PENDING)
processor.processOne(ProcState(headId, ProcStatus.PENDING))
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
val next = flightSchd.findNextStateByFlid("FL_GTDT_E2E")!!
assertEquals(3, next.collections["GTDT"]!!.size)
assertEquals("3", next.collections["GTDT"]!![1]["GTNO"])
assertEquals("C3", next.collections["GTDT"]!![2]["GATE"])
assertEquals(1L, next.stateVersion)
val roundtrip = FlightStateEngine.fromFlightFields("FL_GTDT_E2E", flightSchd.findByFlid("FL_GTDT_E2E")!!)
assertEquals(3, roundtrip.collections["GTDT"]!!.size)
}
}
@@ -1,388 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeFailure
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.NotifyPayload
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.SchdPush
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
/**
* U08/U10/U11processOne 失败闭环——
* ① 意外异常在边界内写 FAILED(INFRA)+退避,重试达上限转 DEAD(EXHAUSTED)Pump 内部异常不杀泵);
* ② 未注册/CODEC_ERROR/staging 未实装 → FAILED(绝不直接终态);MALFORMED → DEAD
* ③ InterruptedException/致命 Error 不被普通恢复逻辑吞掉。
*/
class MessageProcessorTest {
private class FakeProcState : ProcStateRepository {
var record = mutableMapOf<Long, ProcState>()
val bound = mutableMapOf<String, Long>()
override fun insert(cminmsgsId: Long, state: ProcStatus) { record[cminmsgsId] = ProcState(cminmsgsId, state) }
override fun exists(cminmsgsId: Long): Boolean = record.containsKey(cminmsgsId)
override fun headUnfinished(): ProcState? = null
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
val owner = bound[identityKey]
if (owner != null && owner != cminmsgsId) return false
bound[identityKey] = cminmsgsId
record[cminmsgsId] = record[cminmsgsId]?.copy(identityKey = identityKey) ?: ProcState(cminmsgsId, ProcStatus.PENDING, identityKey = identityKey)
return true
}
override fun ownerOfIdentity(identityKey: String): Long? = bound[identityKey]
override fun update(
cminmsgsId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
errorClass: ErrorClass?, lastError: String?,
) {
val old = record[cminmsgsId] ?: ProcState(cminmsgsId, state)
record[cminmsgsId] = old.copy(
state = state,
nextAttemptAt = nextAttemptAt ?: old.nextAttemptAt,
attempts = attempts ?: old.attempts,
errorClass = errorClass ?: old.errorClass,
lastError = lastError ?: old.lastError,
)
}
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
var n = 0
record.keys.toList().forEach { id ->
val s = record[id]!!
if (s.errorClass != null && s.errorClass in errorClasses &&
(s.state == ProcStatus.FAILED || s.state == ProcStatus.DEAD)) {
record[id] = s.copy(state = ProcStatus.PENDING, attempts = 0, nextAttemptAt = null)
n++
}
}
return n
}
fun state(id: Long): ProcState = record.getValue(id)
}
private class FakeInbox : CminmsgInboxRepository {
var raws = mutableMapOf<Long, String>()
var throwOnRawOf: Throwable? = null
var throwOnBackfill: Throwable? = null
val backfilled = mutableListOf<List<Any?>>()
override fun insertRaw(rawXml: String): Long = 0
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun rawOf(cminmsgsId: Long): String? {
throwOnRawOf?.let { throw it }
return raws[cminmsgsId]
}
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
throwOnBackfill?.let { throw it }
backfilled += listOf(cminmsgsId, sndr, type, styp, seqn)
}
}
private class FakeEvents : MsgEventRepository {
val inserted = mutableListOf<List<MsgEvent>>()
override fun insertAll(events: List<MsgEvent>): List<Long> { inserted += events; return events.indices.map { it.toLong() + 1 } }
override fun headUnsent(target: String): MsgEvent? = null
override fun claimBatch(target: String, limit: Int): List<MsgEvent> = emptyList()
override fun markSent(eventId: Long) = Unit
override fun markAllSent(eventIds: List<Long>) = Unit
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) = Unit
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) = Unit
override fun insertSync(events: List<MsgEvent>) = Unit
}
private class FakeCodec : XmlCodec {
var result: DecodeResult = DecodeResult.Ok(
DecodedMessage(
meta = MetaFields(sndr = "AODB", type = "FLOP", styp = "DELY", seqn = 1L, dttm = 20260906120000L),
kind = MsgKind.Flop("DELY"),
rawXml = "<MSG/>",
),
)
override fun decode(rawXml: String): DecodeResult = result
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
}
private class FakeReqTrack : ReqTrackRepository {
override fun findOpenByKind(kind: String): ReqTrackRepository.Req? = null
override fun forceExpireOpenOf(kind: String) = Unit
override fun insert(kind: String, paramsJson: String): Long = 1L
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) = Unit
override fun markSent(reqId: Long, sentAt: Instant) = Unit
override fun expireIfWaiting(reqId: Long) = Unit
override fun markDone(reqId: Long) = Unit
}
private class FakeFlightSchd : FlightSchdRepository {
val flights = mutableMapOf<String, FlightFields>()
val gens = mutableMapOf<String, FlightSchdRepository.GenMeta>()
val incrementalChanges = mutableListOf<com.gzzn.omms.msgexchange.domain.FlightChange>()
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int = 0
override fun findByFlid(flid: String): FlightFields? = flights[flid]
override fun findByFlids(flids: Collection<String>): Map<String, FlightFields> =
flids.mapNotNull { f -> flights[f]?.let { f to it } }.toMap()
override fun findByDay(day: String): List<Pair<String, FlightFields>> = flights.map { it.key to it.value }
override fun findAll(): Map<String, FlightFields> = flights.toMap()
override fun deleteByFlids(flids: Set<String>): Int = 0
override fun getGen(day: String): FlightSchdRepository.GenMeta? = gens[day]
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
val cur = gens[day]?.version ?: 0L
if (cur != expected) return false
gens[day] = newGen
return true
}
override fun deleteGenBefore(cutoffDay: String): Int = 0
override fun findNextStateByFlid(flid: String): com.gzzn.omms.msgexchange.domain.flight.FlightNextState? =
flights[flid]?.let { com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.fromFlightFields(flid, it) }
override fun persistNextStates(
day: String?,
states: List<com.gzzn.omms.msgexchange.domain.flight.FlightNextState>,
snapshotReplace: Boolean,
now: Instant,
) {
states.forEach { state ->
val scalarFields = linkedMapOf("FLID" to state.flid)
state.scalars.forEach { (k, v) -> scalarFields[k] = v }
flights[state.flid] = scalarFields + state.collections.mapValues { (_, items) ->
com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(items)
}
}
}
}
private class FakeTxManager : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
// ---------- helpers ----------
private val clock = MutableClock(MutableClock.BASE)
private fun msg(seqn: String) = DecodedMessage(
meta = MetaFields(sndr = "AODB", type = "FLOP", styp = "DELY", seqn = seqn.toLong(), dttm = 20260906120000L),
kind = MsgKind.Flop("DELY"),
rawXml = "<MSG/>",
)
private fun head(id: Long = 1, status: ProcStatus = ProcStatus.PENDING, attempts: Int = 0, identityKey: String? = null) =
ProcState(id, status, identityKey = identityKey, attempts = attempts)
private fun deliveHandler() = object : Handler {
override val kind: MsgKind = MsgKind.Flop("DELY")
override fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision = Decision(
msgNotifies = listOf(NotifyPayload("""{"n":1}""")),
schdPush = listOf(SchdPush(flid = "F1", payloadJson = """{"FLID":"F1","v":2}""")),
)
}
private fun processor(
procState: FakeProcState, inbox: FakeInbox, events: FakeEvents, codec: FakeCodec,
registry: HandlerRegistry = HandlerRegistry(emptyList()),
backfillTodo: BackfillTodoRepository? = null,
): MessageProcessor {
val props = PipelineProps()
val scheduler = FailureScheduler(props, clock)
val procFailure = ProcFailure(procState, scheduler)
val flightSchd = FakeFlightSchd()
val txManager = FakeTxManager()
val snapshot = SnapshotFlow(procState, flightSchd, events, FakeReqTrack(), procFailure, txManager, inbox, backfillTodo = backfillTodo)
return MessageProcessor(inbox, procState, events, CodecHolder(codec), HandlerHolder(registry), flightSchd, snapshot, procFailure, props, txManager, backfillTodo)
}
// ---------- tests ----------
@Test
fun `no handler is FAILED UNSUPPORTED with backoff - never terminal`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.FAILED, s.state)
assertEquals(ErrorClass.UNSUPPORTED, s.errorClass)
assertEquals(1, s.attempts)
assertNotNull(s.nextAttemptAt)
assertTrue(s.lastError?.startsWith("no-handler:FLOP-DELY") == true)
}
@Test
fun `CODEC_ERROR decode failure is FAILED retryable`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
val codec = FakeCodec().apply { result = DecodeResult.Err(DecodeFailure(ErrorClass.CODEC_ERROR, "boom")) }
processor(ps, inbox, ev, codec).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.FAILED, s.state)
assertEquals(ErrorClass.CODEC_ERROR, s.errorClass)
assertEquals(1, s.attempts)
assertNotNull(s.nextAttemptAt)
}
@Test
fun `MALFORMED decode failure stays DEAD terminal`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
val codec = FakeCodec().apply { result = DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "bad-xml")) }
processor(ps, inbox, ev, codec).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.DEAD, s.state)
assertEquals(ErrorClass.MALFORMED, s.errorClass)
}
@Test
fun `legacy failed head at max attempts upgrades to DEAD at entry`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(status = ProcStatus.FAILED, attempts = 5, identityKey = "k")
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
assertEquals(ProcStatus.DEAD, ps.state(1).state)
assertEquals(ErrorClass.EXHAUSTED, ps.state(1).errorClass)
}
@Test
fun `success path inserts events backfills and marks SUCCEEDED`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
val registry = HandlerRegistry(listOf(deliveHandler()))
processor(ps, inbox, ev, FakeCodec(), registry).processOne(ps.state(1))
assertEquals(ProcStatus.SUCCEEDED, ps.state(1).state)
val events = ev.inserted.single()
assertEquals(listOf(Targets.KAFKA_MSG, Targets.KAFKA_SCHD), events.map { it.target })
assertEquals("F1", events.first { it.target == Targets.KAFKA_SCHD }.partitionKey)
assertEquals(listOf(1L, "AODB", "FLOP", "DELY", 1L), inbox.backfilled.single())
}
@Test
fun `duplicate identity leads to SKIPPED`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.raws[1] = "<MSG/>"
ps.bound["AODB|FLOP|DELY|1"] = 2L // 另一条消息已持有该键
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.SKIPPED, s.state)
assertTrue(s.lastError == "duplicate-of:2")
assertTrue(ev.inserted.isEmpty())
}
@Test
fun `skipped duplicate still compensates mailbox backfill and persists todo on failure`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.raws[1] = "<MSG/>"
inbox.throwOnBackfill = IllegalStateException("mysql-down")
ps.bound["AODB|FLOP|DELY|1"] = 2L
val todo = com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo()
processor(ps, inbox, ev, FakeCodec(), backfillTodo = todo).processOne(ps.state(1))
// SKIPPED 不变,但共享信箱回填的补偿待办必须落库(否则重复报文被反复轮询)
assertEquals(ProcStatus.SKIPPED, ps.state(1).state)
assertEquals(1, todo.count())
assertEquals(1L, todo.tasks().single().cminmsgsId)
assertEquals("mysql-down", todo.lastErrorOf(1L))
}
// ---------- U08 边界化(评审要求①③) ----------
@Test
fun `unexpected exception maps to FAILED INFRA with backoff at boundary`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = RuntimeException("db-down")
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.FAILED, s.state)
assertEquals(ErrorClass.INFRA, s.errorClass)
assertEquals(1, s.attempts)
assertNotNull(s.nextAttemptAt)
assertTrue(s.lastError == "db-down")
}
@Test
fun `repeated unexpected exceptions escalate to DEAD EXHAUSTED at max attempts`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = RuntimeException("db-down")
val p = processor(ps, inbox, ev, FakeCodec())
var attempts = 0
while (ps.state(1).state != ProcStatus.DEAD && attempts < 10) {
p.processOne(ps.state(1))
clock.advance(60_000) // 推进时钟:让退避/毒丸时间语义确定
attempts++
}
val s = ps.state(1)
assertEquals(ProcStatus.DEAD, s.state, "should exhaust within maxAttempts(=5)")
assertEquals(ErrorClass.EXHAUSTED, s.errorClass)
assertTrue(s.lastError?.contains("attempts=5") == true)
assertEquals(5, s.attempts)
}
@Test
fun `fatal Error is not swallowed by exception recovery`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = AssertionError("fatal")
assertThrows(AssertionError::class.java) {
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
}
// 未被普通恢复逻辑改写成 FAILEDError 不落入 catch Exception
assertEquals(ProcStatus.PENDING, ps.state(1).state)
}
@Test
fun `InterruptedException restores flag and propagates`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = InterruptedException("stop")
assertThrows(InterruptedException::class.java) {
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
}
assertTrue(Thread.interrupted(), "interrupt flag must be restored")
}
}
@@ -0,0 +1,205 @@
package com.gzzn.omms.msgexchange.processing
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.codec.ScheduleBody
import com.gzzn.omms.msgexchange.config.OperationDayProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
import com.gzzn.omms.msgexchange.domain.SnapshotResult
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.domain.flight.FlightState
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogRepository
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
import com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.LocalDate
/**
* §5.1 applyScheduleRecords 不变量:重放幂等、完整性校验整包拒绝 DEAD(PROTOCOL)、
* 归属日冲突不落地、DELETED 不恢复且记冲突告警、成功路径版本推进+事件+留痕+待办预登记。
*/
class ScheduleProcessorTest {
private val msgId = 42L
private fun head() = ProcState(msgId, ProcStatus.PENDING)
private fun message(body: ScheduleBody) = DecodedMessage(
meta = MetaFields("AODB", "SCHD", "DNLD", 100L, 1L),
kind = MsgKind.Schd(MsgKind.SchdSubtype.DNLD),
rawXml = "<MSG/>",
body = body,
)
private fun makeBody(vararg recs: Pair<String, String>) = ScheduleBody(
recsDeclared = recs.size,
records = recs.map { (flid, sodt) ->
com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord(flid, mapOf("SODT" to sodt))
},
)
private class TxRunner(private val failOn: () -> Boolean) : PipelineTransactionManager {
var entered = 0
override fun <T> inTransaction(block: () -> T): T {
entered++
if (failOn()) throw IllegalStateException("db-down")
return block()
}
}
private fun processor(
proc: StubProcState = StubProcState(),
flights: StubFlightState = StubFlightState(),
events: StubMsgEvents = StubMsgEvents(),
log: StubSnapshotLog = StubSnapshotLog(),
todo: StubBackfillTodo? = StubBackfillTodo(),
tx: PipelineTransactionManager? = null,
): ScheduleProcessor {
val txRunner = tx ?: object : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
return ScheduleProcessor(
txManager = txRunner,
lock = object : PipelineLockRepository { override fun lock() = Unit },
procState = proc,
flightState = flights,
msgEvents = events,
snapshotLog = log,
backfillTodo = todo,
operationDayProps = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 },
mapper = ObjectMapper(),
)
}
@Test
fun `happy path persists snapshot with version bump, events, snap log and backfill todo`() {
val flights = StubFlightState()
val events = StubMsgEvents()
val log = StubSnapshotLog()
val todo = StubBackfillTodo()
val result = processor(flights = flights, events = events, log = log, todo = todo)
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
assertEquals(ApplyResult.Succeeded, result)
val main = flights.findMainRow("121")!!
assertEquals(1L, main.stateVersion)
assertEquals(LocalDate.of(2026, 12, 15), main.operationDay)
assertEquals(FlightState.ACTIVE, main.state)
assertEquals(2, events.rows.size) // KAFKA_SCHD 整态 + KAFKA_MSG 通知
assertEquals(1, log.entries.size)
assertEquals(SnapshotResult.COMMITTED, log.entries.single().result)
assertEquals(1, log.entries.single().upserted)
assertEquals(1, todo.count()) // §7.2 事务内预登记
}
@Test
fun `replay of succeeded message records idempotent success without writes`() {
val proc = StubProcState()
proc.insert(msgId, ProcStatus.SUCCEEDED)
val flights = StubFlightState()
val log = StubSnapshotLog()
val result = processor(proc = proc, flights = flights, log = log)
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
assertEquals(ApplyResult.ReplaySkipped, result) // §5.1 步骤 2
assertNull(flights.findMainRow("121"))
assertEquals(SnapshotResult.REPLAY_SKIPPED, log.entries.single().result)
}
@Test
fun `validation failure is DEAD protocol without any persistence`() {
val flights = StubFlightState()
val log = StubSnapshotLog()
val schdBody = ScheduleBody(recsDeclared = 2, records = makeBody("121" to "15DEC261723").records)
val result = processor(flights = flights, log = log).applyScheduleRecords(head(), message(schdBody)) as ApplyResult.DeadProtocol
assertTrue(result.flags.contains(SnapshotFlag.RECS_DROP)) // §5.2 RECS = 实收数
assertNull(flights.findMainRow("121"))
assertEquals(SnapshotResult.ROLLED_BACK, log.entries.single().result)
}
@Test
fun `same flid across operation days is rejected whole-batch without writes`() {
val flights = StubFlightState()
// 预置 121 已归属 12-15;报文声称 12-16 → §5.3 第四行
flights.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"121", LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 1, mapOf("SODT" to "15DEC261723"), emptyMap(),
),
msgId = 1,
now = java.time.Instant.now(),
)
val log = StubSnapshotLog()
val result = processor(flights = flights, log = log).applyScheduleRecords(
head(), message(makeBody("121" to "16DEC261723")),
)
assertTrue(result is ApplyResult.DeadProtocol)
assertEquals(LocalDate.of(2026, 12, 15), flights.findMainRow("121")!!.operationDay) // 不迁移归属
assertEquals(1L, flights.findMainRow("121")!!.stateVersion) // 不改写
assertEquals(SnapshotResult.ROLLED_BACK, log.entries.single().result)
}
@Test
fun `DELETED flight stays deleted with revive conflict flag`() {
val flights = StubFlightState()
flights.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"121", LocalDate.of(2026, 12, 15), FlightState.DELETED, 7,
mapOf("SODT" to "15DEC261723"), emptyMap(),
),
msgId = 1,
now = java.time.Instant.now(),
)
val log = StubSnapshotLog()
val result = processor(flights = flights, log = log).applyScheduleRecords(
head(), message(makeBody("121" to "15DEC261723")),
)
assertEquals(ApplyResult.Succeeded, result)
assertEquals(FlightState.DELETED, flights.findMainRow("121")!!.state) // §5.1 步骤 6 不恢复
assertEquals(8L, flights.findMainRow("121")!!.stateVersion)
assertEquals(SnapshotResult.COMMITTED, log.entries.single().result)
assertTrue(log.entries.single().flags.contains(SnapshotFlag.SCHD_REVIVE_CONFLICT))
}
@Test
fun `infra failure inside transaction throws and leaves nothing persisted`() {
val proc = StubProcState()
val flights = StubFlightState()
val log = StubSnapshotLog()
val todo = StubBackfillTodo()
proc.insert(msgId)
val p = processor(proc = proc, flights = flights, log = log, todo = todo, tx = TxRunner { true })
// §9:数据库/内部故障 → 异常上抛,MessageProcessor 记 FAILED(INFRA) 退避;不写任何终态
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException::class.java) {
p.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
}
assertNull(flights.findMainRow("121"))
assertEquals(0, todo.count())
assertEquals(0, log.entries.size)
assertEquals(ProcStatus.PENDING, proc.find(msgId)!!.state)
}
}
@@ -1,118 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/** v2 §2.6 / §5:提交后回填失败不得回滚 PROC_STATE。 */
class SnapshotFlowBackfillTest {
private class FailingInbox : CminmsgInboxRepository {
override fun insertRaw(rawXml: String): Long = 1L
override fun rawOf(cminmsgsId: Long): String? = null
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
throw RuntimeException("mysql-down")
}
}
@Test
fun `backfill failure after commit leaves PROC_STATE SUCCEEDED`() {
val flightSchd = StubFlightSchd()
val procState = StubProcState()
val msgEvents = StubMsgEvents()
val reqTrack = StubReqTrack()
val txManager = StubPipelineTransactionManager()
val inbox = FailingInbox()
val clock = MutableClock(MutableClock.BASE)
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), clock))
val headId = 901L
procState.insert(headId, ProcStatus.PENDING)
val day = "2026-09-07"
val meta = MetaFields("AODB", "SCHD", "DNLD", 9L, 20260907090000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
SnapshotFlow.StageResult.parser = {
SnapshotFlow.StageResult.Ok(day, listOf("FL_X" to mapOf("FLID" to "FL_X")))
}
// v2 §5(P2-3):回填失败必须落持久补偿待办(否则重启后无从对账)
val todo = com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo()
val flow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox, backfillTodo = todo)
flow.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded)
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
assertTrue(flightSchd.findByFlid("FL_X") != null)
assertEquals(1, todo.count())
val recorded = todo.tasks().single()
assertEquals(901L, recorded.cminmsgsId)
assertEquals("SCHD", recorded.type)
assertEquals("DNLD", recorded.styp)
assertEquals("mysql-down", todo.lastErrorOf(901L))
}
@Test
fun `CAS replay requires matching lastMessageId not version alone`() {
val base = StubFlightSchd()
val procState = StubProcState()
val msgEvents = StubMsgEvents()
val reqTrack = StubReqTrack()
val txManager = StubPipelineTransactionManager()
val inbox = object : CminmsgInboxRepository {
override fun insertRaw(rawXml: String) = 1L
override fun rawOf(cminmsgsId: Long) = null
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
}
val clock = MutableClock(MutableClock.BASE)
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), clock))
val headId = 902L
val day = "2026-09-07"
procState.insert(headId, ProcStatus.PENDING)
val conflictingGen = FlightSchdRepository.GenMeta(day, 2L, setOf("OTHER"), lastMessageId = "999")
base.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("OTHER"), lastMessageId = "999"))
val mockFlightSchd = object : FlightSchdRepository by base {
override fun putGenIfVersion(
day: String,
expected: Long,
newGen: FlightSchdRepository.GenMeta,
now: java.time.Instant,
): Boolean {
base.putGenIfVersion(day, 1L, conflictingGen)
return false
}
}
SnapshotFlow.StageResult.parser = {
SnapshotFlow.StageResult.Ok(day, listOf("FL_Y" to mapOf("FLID" to "FL_Y")))
}
val meta = MetaFields("AODB", "SCHD", "DNLD", 10L, 20260907100000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
val flow = SnapshotFlow(procState, mockFlightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
flow.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded)
assertEquals(ProcStatus.FAILED, procState.snapshotOf(headId)!!.state)
}
}
@@ -1,111 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
import com.gzzn.omms.msgexchange.processing.handlers.GtdtHandler
import com.gzzn.omms.msgexchange.support.GuardedFlightSchd
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Paths
import kotlin.io.path.readText
class V2PipelineWritePathContractTest {
@Test
fun `SnapshotFlow and MessageProcessor sources must not call legacy upsert helpers`() {
val processingDir = Paths.get("src/main/kotlin/com/gzzn/omms/msgexchange/processing")
val snapshotSource = processingDir.resolve("SnapshotFlow.kt").readText()
val pumpSource = processingDir.resolve("Pump.kt").readText()
assertFalse(snapshotSource.contains("upsertSnapshotBatch"))
assertFalse(snapshotSource.contains("upsertIncremental"))
assertTrue(snapshotSource.contains("persistNextStates"))
assertFalse(pumpSource.contains("upsertSnapshotBatch"))
assertFalse(pumpSource.contains("upsertIncremental"))
assertTrue(pumpSource.contains("persistNextStates"))
}
@Test
fun `SnapshotFlow DNLD path calls persistNextStates not legacy upsert helpers`() {
val flightSchd = GuardedFlightSchd()
val procState = StubProcState()
val msgEvents = StubMsgEvents()
val reqTrack = StubReqTrack()
val txManager = StubPipelineTransactionManager()
val inbox = object : CminmsgInboxRepository {
override fun insertRaw(rawXml: String) = 1L
override fun rawOf(cminmsgsId: Long) = null
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
}
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), MutableClock(MutableClock.BASE)))
val headId = 1001L
procState.insert(headId, ProcStatus.PENDING)
val day = "2026-09-07"
SnapshotFlow.StageResult.parser = {
SnapshotFlow.StageResult.Ok(day, listOf("FL_DNLD" to mapOf("FLID" to "FL_DNLD", "FLNO" to "CA100", "GTDT" to """[{"GTNO":"1","GATE":"A1"}]""")))
}
val decoded = DecodedMessage(MetaFields("AODB", "SCHD", "DNLD", 11L, 20260907110000L), MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded)
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
assertEquals(1, flightSchd.persistNextStatesCalls)
}
@Test
fun `MessageProcessor FLOP path calls persistNextStates not legacy upsert helpers`() {
val flightSchd = GuardedFlightSchd()
val procState = StubProcState()
val msgEvents = StubMsgEvents()
val reqTrack = StubReqTrack()
val txManager = StubPipelineTransactionManager()
val raws = mutableMapOf<Long, String>()
val inbox = object : CminmsgInboxRepository {
override fun insertRaw(rawXml: String) = 1L
override fun rawOf(cminmsgsId: Long) = raws[cminmsgsId]
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
}
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), MutableClock(MutableClock.BASE)))
val handler = GtdtHandler()
val snapshot = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val processor = MessageProcessor(
inbox, procState, msgEvents,
CodecHolder(JacksonXmlCodec()),
HandlerHolder(HandlerRegistry(listOf(handler))),
flightSchd, snapshot, procFailure, PipelineProps(), txManager,
)
val headId = 1002L
procState.insert(headId, ProcStatus.PENDING)
raws[headId] = """
<MSG>
<META><SNDR>AODB</SNDR><SEQN>1</SEQN><DTTM>20260907120000</DTTM><TYPE>FLOP</TYPE><STYP>GTDT</STYP></META>
<FLOP><FLID>FL_FLOP</FLID><GTDT GTNO="2"><GATE>B9</GATE></GTDT></FLOP>
</MSG>
""".trimIndent()
processor.processOne(ProcState(headId, ProcStatus.PENDING))
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
assertEquals(1, flightSchd.persistNextStatesCalls)
}
}
@@ -1,41 +0,0 @@
package com.gzzn.omms.msgexchange.processing.handlers
import com.gzzn.omms.msgexchange.codec.FlopPayload
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgKind
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class GtdtHandlerTest {
private val handler = GtdtHandler()
@Test
fun `decide replaces GTDT collection for three gates`() {
val msg = DecodedMessage(
meta = MetaFields("AODB", "FLOP", "GTDT", 1L, 20260908120000L),
kind = MsgKind.Flop("GTDT"),
rawXml = "<MSG/>",
body = FlopPayload(
flid = "121112312",
scalars = mapOf("FFID" to "CA-CA101-A-12DEC031345-D"),
collections = mapOf(
"GTDT" to listOf(
mapOf("GTNO" to "1", "GATE" to "G28", "GTYP" to "D"),
mapOf("GTNO" to "2", "GATE" to "G33", "GTYP" to "I"),
mapOf("GTNO" to "3", "GATE" to "G23", "GTYP" to "D"),
),
),
),
)
val decision = handler.decide(emptyMap(), msg)
assertEquals(1, decision.flightChanges.size)
assertEquals("121112312", decision.flightChanges[0].flid)
val gtdtJson = decision.flightChanges[0].fields["GTDT"]!!
assertEquals(true, gtdtJson.contains("\"G28\""))
assertEquals(1, decision.schdPush.size)
assertEquals("121112312", decision.schdPush[0].flid)
}
}
@@ -1,44 +0,0 @@
package com.gzzn.omms.msgexchange.support
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import java.time.Instant
/** 测试种子助手:v2 写路径(persistNextStates)等价替代已退场的 legacy upsertP4)。 */
fun seedSnapshot(
repo: FlightSchdRepository,
day: String,
flights: List<Pair<String, FlightFields>>,
now: Instant = Instant.now(),
) {
repo.persistNextStates(
day,
flights.map { (flid, fields) ->
FlightStateEngine.apply(
null,
FlightStateEngine.commandsFromFields(flid, fields),
"seed-$day",
bumpVersion = false,
).copy(stateVersion = 1L)
},
snapshotReplace = true,
now = now,
)
}
fun seedIncremental(repo: FlightSchdRepository, changes: List<FlightChange>, messageId: String = "seed-inc") {
repo.persistNextStates(
null,
changes.map { change ->
FlightStateEngine.apply(
repo.findNextStateByFlid(change.flid),
FlightStateEngine.commandsFromFields(change.flid, change.fields),
messageId,
bumpVersion = true,
)
},
snapshotReplace = false,
)
}
@@ -1,31 +0,0 @@
package com.gzzn.omms.msgexchange.support
import com.gzzn.omms.msgexchange.domain.flight.FlightNextState
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import java.time.Instant
/** v2 写路径守卫(P4 退场后):persistNextStates 是唯一写路径,legacy upsert 入口已从接口删除。 */
class GuardedFlightSchd(
private val inner: FlightSchdRepository = StubFlightSchd(),
) : FlightSchdRepository {
var persistNextStatesCalls = 0
override fun persistNextStates(day: String?, states: List<FlightNextState>, snapshotReplace: Boolean, now: Instant) {
persistNextStatesCalls++
inner.persistNextStates(day, states, snapshotReplace, now)
}
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int = inner.deleteDiffByDay(day, delFlids)
override fun findByFlid(flid: String): FlightFields? = inner.findByFlid(flid)
override fun findNextStateByFlid(flid: String): FlightNextState? = inner.findNextStateByFlid(flid)
override fun findByFlids(flids: Collection<String>): Map<String, FlightFields> = inner.findByFlids(flids)
override fun findByDay(day: String): List<Pair<String, FlightFields>> = inner.findByDay(day)
override fun findAll(): Map<String, FlightFields> = inner.findAll()
override fun deleteByFlids(flids: Set<String>): Int = inner.deleteByFlids(flids)
override fun getGen(day: String): FlightSchdRepository.GenMeta? = inner.getGen(day)
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean =
inner.putGenIfVersion(day, expected, newGen, now)
override fun deleteGenBefore(cutoffDay: String): Int = inner.deleteGenBefore(cutoffDay)
}
@@ -1,115 +0,0 @@
package com.gzzn.omms.msgexchange.tools
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class FlightStoreDiffToolTest {
private val tool = FlightStoreDiffTool()
@Test
fun `Field map comparison ignores legacy JSON key ordering differences`() {
val pg = mapOf(
"F1" to mapOf(
"flid" to "F1",
"airline" to "CA",
"flightNo" to "123",
"status" to "SCHD",
),
)
val legacy = mapOf(
"F1" to """{"status":"SCHD","flightNo":"123","airline":"CA","flid":"F1"}""",
)
val report = tool.diff(pg, legacy)
assertTrue(report.isGreen)
assertEquals(1, report.matchedCount)
assertEquals(0, report.unexpectedDeviations.size)
}
@Test
fun `Field map comparison normalizes numeric precision and null equivalents`() {
val pg = mapOf(
"F1" to mapOf(
"flid" to "F1",
"weight" to "100.0",
"delay" to "0",
),
)
val legacy = mapOf(
"F1" to """{"flid":"F1","weight":100,"delay":0.0,"extra":null}""",
)
val report = tool.diff(pg, legacy)
assertTrue(report.isGreen)
assertEquals(1, report.matchedCount)
assertEquals(0, report.unexpectedDeviations.size)
}
@Test
fun `Recognizes known deviation 1 - Cross-day migrated flights protected in PG`() {
// PG 中包含跨日迁移未被误删的航班,Legacy Redis 中已被误删
val pg = mapOf(
"F_STABLE" to mapOf("flid" to "F_STABLE"),
"F_MIGRATED" to mapOf("flid" to "F_MIGRATED", "FDAY" to "2026-09-08"),
)
val legacy = mapOf(
"F_STABLE" to """{"flid":"F_STABLE"}""",
// F_MIGRATED 在 legacy 中被按旧代差删误删
)
val report = tool.diff(pg, legacy, crossDayMigratedFlids = setOf("F_MIGRATED"))
assertTrue(report.isGreen) // 属于已知合法偏差,红绿灯依然为 GREEN
assertEquals(1, report.matchedCount)
assertEquals(1, report.knownDeviations.size)
assertEquals(FlightStoreDiffTool.DeviationKind.CROSS_DAY_PROTECTED, report.knownDeviations[0].kind)
assertEquals(0, report.unexpectedDeviations.size)
}
@Test
fun `Detects real field mismatch and missing flight as unexpected deviation`() {
val pg = mapOf(
"F1" to mapOf("flid" to "F1", "status" to "BOARDING"),
"F_EXTRA" to mapOf("flid" to "F_EXTRA"),
)
val legacy = mapOf(
"F1" to """{"flid":"F1","status":"DEPARTED"}""",
"F_MISSING" to """{"flid":"F_MISSING"}""",
)
val report = tool.diff(pg, legacy)
assertFalse(report.isGreen)
assertEquals(0, report.matchedCount)
assertEquals(3, report.unexpectedDeviations.size)
val kinds = report.unexpectedDeviations.map { it.kind }
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.FIELD_MISMATCH))
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.MISSING_IN_PG))
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.UNEXPECTED_EXTRA_IN_PG))
val summary = report.formatSummary()
assertTrue(summary.contains("RED (未通过)"))
assertTrue(summary.contains("F1"))
}
@Test
fun `Nested collection value is compared as parsed JSON tree`() {
val pg = mapOf(
"F1" to mapOf(
"flid" to "F1",
// 1:N 明细集合列:序列化文本(JSON 数组),按解析树与 legacy 递归比对
"GTDT" to """[{"GATE":"A1","PGOT":"07SEP260800"}]""",
),
)
val legacy = mapOf(
"F1" to """{"flid":"F1","GTDT":[{"PGOT":"07SEP260800","GATE":"A1"}]}""",
)
val report = tool.diff(pg, legacy)
assertTrue(report.isGreen)
assertEquals(1, report.matchedCount)
assertEquals(0, report.unexpectedDeviations.size)
}
}