diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/PipelineSmokeTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/PipelineSmokeTest.kt index 12128dd..f4bebe8 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/PipelineSmokeTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/PipelineSmokeTest.kt @@ -57,33 +57,43 @@ class PipelineSmokeTest { assertNotNull(controller) } + companion object { + /** 合法 META + FLOP,但未注册 Handler → FAILED(UNSUPPORTED) */ + val UNSUPPORTED_FLOP_XML = """ + + AODB120260908120000FLOPDELY + F1 + + """.trimIndent() + } + @Test - fun `accept then pump tick transitions message to FAILED CODEC_ERROR with backoff`() { - val receipt = controller.send("") + fun `accept then pump tick transitions message to FAILED UNSUPPORTED with backoff`() { + val receipt = controller.send(UNSUPPORTED_FLOP_XML) assertNotNull(receipt.body()) // 受理 ID val id = receipt.body()!!.toLong() - pump.tick() // 主泵领取 → stub codec 未实装 → FAILED(CODEC_ERROR) + pump.tick() // 解码成功但无 DELY Handler → FAILED(UNSUPPORTED) val stub = ctx.getBean(StubProcState::class.java) val s = stub.snapshotOf(id) assertNotNull(s) assertEquals(ProcStatus.FAILED, s!!.state) - assertEquals(ErrorClass.CODEC_ERROR, s.errorClass) + assertEquals(ErrorClass.UNSUPPORTED, s.errorClass) assertEquals(1, s.attempts) assertNotNull(s.nextAttemptAt) } @Test - fun `replay reopens failed CODEC_ERROR row to PENDING`() { - val receipt = controller.send("") + fun `replay reopens failed UNSUPPORTED row to PENDING`() { + val receipt = controller.send(UNSUPPORTED_FLOP_XML) val id = receipt.body()!!.toLong() pump.tick() val stub = ctx.getBean(StubProcState::class.java) assertEquals(ProcStatus.FAILED, stub.snapshotOf(id)!!.state) val n = ctx.getBean(com.gzzn.omms.msgexchange.infra.retry.ReplayService::class.java) - .replay(listOf(ErrorClass.CODEC_ERROR)) + .replay(listOf(ErrorClass.UNSUPPORTED)) assertEquals(1, n) val reopened = stub.snapshotOf(id)!! diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt new file mode 100644 index 0000000..6b6a99b --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt @@ -0,0 +1,94 @@ +package com.gzzn.omms.msgexchange.codec + +import com.gzzn.omms.msgexchange.domain.MsgKind +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class JacksonXmlCodecTest { + + private val codec = JacksonXmlCodec() + + @Test + fun `decode AODBGTDT sample extracts META and three gates`() { + val raw = """ + + + + AODB + 1243 + 20021010090311 + FLOP + GTDT + + + 121112312 + CA-CA101-A-12DEC031345-D + + G28 + 15DEC031805 + 15DEC031925 + 15DEC031825 + D + + + G33 + 15DEC031805 + 15DEC031925 + I + + + G23 + 15DEC031805 + 15DEC031925 + D + + + + """.trimIndent() + + val result = codec.decode(raw) + assertInstanceOf(DecodeResult.Ok::class.java, result) + val msg = (result as DecodeResult.Ok).message + assertEquals(MsgKind.Flop("GTDT"), msg.kind) + assertEquals("AODB", msg.meta.sndr) + assertEquals(1243L, msg.meta.seqn) + + val body = msg.body as FlopPayload + assertEquals("121112312", body.flid) + assertEquals("CA-CA101-A-12DEC031345-D", body.scalars["FFID"]) + assertEquals(3, body.collections["GTDT"]!!.size) + assertEquals("G28", body.collections["GTDT"]!![0]["GATE"]) + assertEquals("1", body.collections["GTDT"]!![0]["GTNO"]) + assertEquals("G33", body.collections["GTDT"]!![1]["GATE"]) + assertEquals("3", body.collections["GTDT"]!![2]["GTNO"]) + } + + @Test + fun `decode GTNO zero marks gate clear snapshot`() { + val raw = """ + + + AODB120021010090311 + FLOPGTDT + + + F_CLEAR + + + + """.trimIndent() + + val msg = (codec.decode(raw) as DecodeResult.Ok).message + val body = msg.body as FlopPayload + assertEquals(listOf(mapOf("GTNO" to "0")), body.collections["GTDT"]) + } + + @Test + fun `malformed xml returns MALFORMED not CODEC_ERROR`() { + val err = codec.decode("not-xml") as DecodeResult.Err + assertEquals(com.gzzn.omms.msgexchange.domain.ErrorClass.MALFORMED, err.failure.errorClass) + assertTrue(err.failure.detail.contains("empty-or-non-xml")) + } +} diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt new file mode 100644 index 0000000..020e7ff --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt @@ -0,0 +1,109 @@ +package com.gzzn.omms.msgexchange.domain.flight + +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 FlightStateEngineTest { + + @Test + fun `scalar unchanged does not modify existing value`() { + val current = FlightNextState("F1", mapOf("FLNO" to "CA100"), emptyMap(), 1L, "m1") + val next = FlightStateEngine.apply( + current, + FlightFieldCommands("F1", scalars = emptyMap()), + "m2", + bumpVersion = true, + ) + assertEquals("CA100", next.scalars["FLNO"]) + assertEquals(2L, next.stateVersion) + } + + @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"), + ) + 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"]) + } + + @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", + ) + val next = FlightStateEngine.apply( + current, + FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Clear)), + "m2", + bumpVersion = true, + ) + assertFalse(next.collections.containsKey("GTDT")) + } + + @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"), + ) + 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"]) + } + + @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) + } + + @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"), + ), + ), + "m2", + bumpVersion = true, + ) + assertEquals("NEW", next.collections["GTDT"]!![0]["GATE"]) + } +} diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlightSchdJdbcPgTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlightSchdJdbcPgTest.kt index b361055..1e262cf 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlightSchdJdbcPgTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlightSchdJdbcPgTest.kt @@ -11,6 +11,7 @@ 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 @@ -37,22 +38,12 @@ class FlightSchdJdbcPgTest { @BeforeEach fun setUp() { - val url = "jdbc:postgresql://localhost:5432/msgx" - val user = "msgx_dev" - val pass = "msgx_dev_pass" - - // 仅当本地 PostgreSQL 容器可用时执行 - val canConnect = try { - DriverManager.getConnection(url, user, pass).use { true } - } catch (_: Exception) { - false - } - assumeTrue(canConnect, "Local PostgreSQL on port 5432 is not accessible, skipping PG dialect integration tests") + assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage()) ds = HikariDataSource().apply { - jdbcUrl = url - username = user - this.password = pass + jdbcUrl = PgTestSupport.jdbcUrl + username = PgTestSupport.user + password = PgTestSupport.password driverClassName = "org.postgresql.Driver" maximumPoolSize = 2 } @@ -244,7 +235,7 @@ class FlightSchdJdbcPgTest { // 持锁事务内:另一连接 NOWAIT 获取同行锁必须立即失败(互斥生效) assertEquals("held", txManager.inTransaction { - DriverManager.getConnection("jdbc:postgresql://localhost:5432/msgx", "msgx_dev", "msgx_dev_pass").use { other -> + DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { other -> other.autoCommit = false try { other.prepareStatement( diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt index b1fc815..784e360 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt @@ -1,5 +1,6 @@ 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.assertTrue @@ -18,16 +19,10 @@ class FlywayMigrationTest { @Test fun `Flyway automatically migrates V1_0_0 and V1_1_0 onto real PostgreSQL`() { - val url = "jdbc:postgresql://localhost:5432/msgx" - val user = "msgx_dev" - val pass = "msgx_dev_pass" - - val canConnect = try { - DriverManager.getConnection(url, user, pass).use { true } - } catch (_: Exception) { - false - } - assumeTrue(canConnect, "PostgreSQL on port 5432 not accessible, skipping Flyway test") + assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage()) + val url = PgTestSupport.jdbcUrl + val user = PgTestSupport.user + val pass = PgTestSupport.password val flyway = Flyway.configure() .dataSource(url, user, pass) diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/ingress/InboxPollerTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/ingress/InboxPollerTest.kt index 4d858df..ad239e9 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/ingress/InboxPollerTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/ingress/InboxPollerTest.kt @@ -1,5 +1,6 @@ package com.gzzn.omms.msgexchange.ingress +import com.gzzn.omms.msgexchange.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ProcStatus import com.gzzn.omms.msgexchange.infra.stub.StubInbox import com.gzzn.omms.msgexchange.infra.stub.StubProcState @@ -40,6 +41,7 @@ class InboxPollerTest { poller.pollOnce() assertEquals(0, poller.pollOnce()) pump.tick() - assertEquals(ProcStatus.FAILED, stubProc.snapshotOf(id)!!.state) + assertEquals(ProcStatus.DEAD, stubProc.snapshotOf(id)!!.state) + assertEquals(ErrorClass.MALFORMED, stubProc.snapshotOf(id)!!.errorClass) } } diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/GtdtPipelineTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/GtdtPipelineTest.kt new file mode 100644 index 0000000..791ad59 --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/GtdtPipelineTest.kt @@ -0,0 +1,82 @@ +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-1:GTDT 报文端到端 SUCCEEDED + 三门读回(真实 XmlCodec + GtdtHandler)。 */ +class GtdtPipelineTest { + + @Test + fun `GTDT XML end-to-end SUCCEEDED with three gates persisted`() { + val raw = """ + + + + AODB900120260908120000 + FLOPGTDT + + + FL_GTDT_E2E + A1D + B2I + C3D + + + """.trimIndent() + + val procState = StubProcState() + val flightSchd = StubFlightSchd() + val msgEvents = StubMsgEvents() + val reqTrack = StubReqTrack() + val txManager = object : PipelineTransactionManager { + override fun 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() + 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) + } +} diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/MessageProcessorTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/MessageProcessorTest.kt index 653034d..87d1333 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/MessageProcessorTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/MessageProcessorTest.kt @@ -175,6 +175,22 @@ class MessageProcessorTest { 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, + 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 { diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/SnapshotFlowBackfillTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/SnapshotFlowBackfillTest.kt new file mode 100644 index 0000000..8616e80 --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/SnapshotFlowBackfillTest.kt @@ -0,0 +1,110 @@ +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 = 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), "") + SnapshotFlow.StageResult.parser = { + SnapshotFlow.StageResult.Ok(day, listOf("FL_X" to mapOf("FLID" to "FL_X"))) + } + + val flow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox) + flow.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded) + + assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state) + assertTrue(flightSchd.findByFlid("FL_X") != null) + } + + @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 = 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), "") + val flow = SnapshotFlow(procState, mockFlightSchd, msgEvents, reqTrack, procFailure, txManager, inbox) + flow.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded) + + assertEquals(ProcStatus.FAILED, procState.snapshotOf(headId)!!.state) + } +} diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/V2PipelineWritePathContractTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/V2PipelineWritePathContractTest.kt new file mode 100644 index 0000000..8a62a53 --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/V2PipelineWritePathContractTest.kt @@ -0,0 +1,115 @@ +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 = 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), "") + 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) + assertEquals(0, flightSchd.legacySnapshotCalls) + assertEquals(0, flightSchd.legacyIncrementalCalls) + } + + @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() + 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 = 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] = """ + + AODB120260907120000FLOPGTDT + FL_FLOPB9 + + """.trimIndent() + processor.processOne(ProcState(headId, ProcStatus.PENDING)) + + assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state) + assertEquals(1, flightSchd.persistNextStatesCalls) + assertEquals(0, flightSchd.legacySnapshotCalls) + assertEquals(0, flightSchd.legacyIncrementalCalls) + } +} diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/handlers/GtdtHandlerTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/handlers/GtdtHandlerTest.kt new file mode 100644 index 0000000..b50151e --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/handlers/GtdtHandlerTest.kt @@ -0,0 +1,41 @@ +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 = "", + 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) + } +} diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/support/PgTestSupport.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/support/PgTestSupport.kt new file mode 100644 index 0000000..0a79c87 --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/support/PgTestSupport.kt @@ -0,0 +1,30 @@ +package com.gzzn.omms.msgexchange.support + +/** + * JDBC 集成测试 PostgreSQL 连接参数(环境变量化,解决本机 5432 端口冲突)。 + * + * | 变量 | 默认 | + * |---|---| + * | MSGX_PG_URL | jdbc:postgresql://${MSGX_PG_HOST:127.0.0.1}:${MSGX_PG_PORT:5432}/${MSGX_PG_NAME:msgx} | + * | MSGX_PG_USER | msgx_dev | + * | MSGX_PG_PASSWORD | msgx_dev_pass | + */ +object PgTestSupport { + private val host = System.getenv("MSGX_PG_HOST") ?: "127.0.0.1" + private val port = System.getenv("MSGX_PG_PORT") ?: "5432" + private val name = System.getenv("MSGX_PG_NAME") ?: "msgx" + + val jdbcUrl: String = System.getenv("MSGX_PG_URL") + ?: "jdbc:postgresql://$host:$port/$name" + val user: String = System.getenv("MSGX_PG_USER") ?: "msgx_dev" + val password: String = System.getenv("MSGX_PG_PASSWORD") ?: "msgx_dev_pass" + + fun canConnect(): Boolean = try { + java.sql.DriverManager.getConnection(jdbcUrl, user, password).use { true } + } catch (_: Exception) { + false + } + + fun skipMessage(): String = + "PostgreSQL not accessible at $jdbcUrl (set MSGX_PG_URL / MSGX_PG_PORT to avoid port conflicts)" +} diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/support/V2WritePathGuard.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/support/V2WritePathGuard.kt new file mode 100644 index 0000000..7cb5733 --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/support/V2WritePathGuard.kt @@ -0,0 +1,60 @@ +package com.gzzn.omms.msgexchange.support + +import com.gzzn.omms.msgexchange.domain.FlightChange +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 + +/** V1.2.0 宽表集合存储列;v2 persistNextStates 不得写入。 */ +val LEGACY_COLLECTION_STORAGE_COLUMNS: List = listOf( + "gate1", "pgot1", "pgct1", "gotm1", "gctm1", "gtyp1", + "gate2", "pgot2", "pgct2", "gotm2", "gctm2", "gtyp2", + "chkc1", "ccls1", "pcot1", "pcct1", "cotm1", "cctm1", "ctyp1", + "chkc2", "ccls2", "pcot2", "pcct2", "cotm2", "cctm2", "ctyp2", + "chkc3", "ccls3", "pcot3", "pcct3", "cotm3", "cctm3", "ctyp3", + "belt1", "bcls1", "bpcot1", "bpcct1", "fbag1", "lbag1", "btyp1", + "belt2", "bcls2", "bpcot2", "bpcct2", "fbag2", "lbag2", "btyp2", + "psst1", "stst1", "stet1", "psst2", "stst2", "stet2", + "chut1", "chcls1", "pcbt1", "pcet1", "cbtm1", "cetm1", "chtyp1", + "chut2", "chcls2", "pcbt2", "pcet2", "cbtm2", "cetm2", "chtyp2", + "dely_code", "dely_strt", "dely_dura", "dely_remc", + "abtm_a", "abtm_d", "chot_on", "chot_off", + "rout_path", "erut_path", +) + +class GuardedFlightSchd( + private val inner: FlightSchdRepository = StubFlightSchd(), +) : FlightSchdRepository { + var legacySnapshotCalls = 0 + var legacyIncrementalCalls = 0 + var persistNextStatesCalls = 0 + + override fun upsertSnapshotBatch(day: String, flights: List>, now: Instant) { + legacySnapshotCalls++ + throw AssertionError("v2 path must not call upsertSnapshotBatch") + } + + override fun upsertIncremental(changes: List, now: Instant) { + legacyIncrementalCalls++ + throw AssertionError("v2 path must not call upsertIncremental") + } + + override fun persistNextStates(day: String?, states: List, snapshotReplace: Boolean, now: Instant) { + persistNextStatesCalls++ + inner.persistNextStates(day, states, snapshotReplace, now) + } + + override fun deleteDiffByDay(day: String, delFlids: Collection): 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): Map = inner.findByFlids(flids) + override fun findByDay(day: String): List> = inner.findByDay(day) + override fun findAll(): Map = inner.findAll() + override fun deleteByFlids(flids: Set): 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) +}