package com.gzzn.omms.msgexchange.processing import com.gzzn.omms.msgexchange.MutableClock import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec import com.gzzn.omms.msgexchange.config.OperationDayProps import com.gzzn.omms.msgexchange.config.PipelineProps import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys import com.gzzn.omms.msgexchange.domain.RqfdTimeFilter import com.gzzn.omms.msgexchange.ingress.SchdSyncService import com.gzzn.omms.msgexchange.infra.persistence.CoutmsgOutboxRepository import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository import com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack 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.Duration import java.time.LocalDate class OutboundRequestTest { private val clock = MutableClock(MutableClock.BASE) private val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 } private val props = PipelineProps().apply { outbound.responseTimeout = Duration.ofMinutes(5) } private fun service(req: StubReqTrack, outbox: StubCoutmsgOutbox) = OutboundRequestService(req, outbox, JacksonXmlCodec(), props, opDay, clock) @Test fun `schd sync registers PENDING then confirms COUTMSGS as SENT`() { val req = StubReqTrack(clock) val outbox = StubCoutmsgOutbox() val svc = service(req, outbox) val outcome = svc.registerRqfdSync() assertTrue(outcome is OutboundRequestService.RegisterOutcome.Registered) val open = req.findLatest( OutboundRequestKeys.RQFD_REQ_TYPE, LocalDate.of(2026, 9, 6), OutboundRequestKeys.SENDER, listOf(ReqTrackRepository.ReqState.SENT), )!! assertEquals(1L, open.coutmsgsId) assertTrue(outbox.messages.containsValue(outbox.messages.values.first())) assertTrue(outbox.messages.values.first().contains("OMMS")) assertTrue(outbox.messages.values.first().contains("RQFD")) } @Test fun `schd sync writes the page time filter into COUTMSGS and leaves out the rest`() { val req = StubReqTrack(clock) val outbox = StubCoutmsgOutbox() val svc = service(req, outbox) svc.registerRqfdSync(RqfdTimeFilter(stdb = "12JAN041730")) val xml = outbox.messages.values.first() assertTrue(xml.contains("12JAN041730")) assertFalse(xml.contains("STDE")) assertFalse(xml.contains("ETDB")) assertFalse(xml.contains("ETDE")) } @Test fun `invalid time filter is rejected before registration`() { val req = StubReqTrack(clock) val sync = SchdSyncService(service(req, StubCoutmsgOutbox())) assertEquals(SchdSyncService.Outcome.InvalidTime, sync.trigger(stdb = "today")) assertTrue(req.rows.isEmpty()) } @Test fun `open slot rejects duplicate schd sync registration`() { val req = StubReqTrack(clock) val svc = service(req, StubCoutmsgOutbox()) svc.registerRqfdSync() assertEquals(OutboundRequestService.RegisterOutcome.OpenExists, svc.registerRqfdSync()) } @Test fun `restart retries PENDING without coutmsgs after job dispatch`() { val req = StubReqTrack(clock) val outbox = StubCoutmsgOutbox() val day = LocalDate.of(2026, 9, 6) val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER) assertEquals(ReqTrackRepository.ReqState.PENDING, req.rows[reqId]!!.state) val svc = service(req, outbox) assertEquals(1, svc.dispatchPending()) assertEquals(ReqTrackRepository.ReqState.SENT, req.rows[reqId]!!.state) } @Test fun `ambiguous write marks uncertain and does not blind resend`() { val req = StubReqTrack(clock) val outbox = StubCoutmsgOutbox().apply { nextInsertOutcome = CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS } val day = LocalDate.of(2026, 9, 6) val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER) val svc = service(req, outbox) assertFalse(svc.dispatchPending() > 0) assertTrue(req.rows[reqId]!!.writeUncertain) assertEquals(ReqTrackRepository.ReqState.PENDING, req.rows[reqId]!!.state) outbox.nextInsertOutcome = CoutmsgOutboxRepository.InsertOutcome.CONFIRMED assertEquals(0, svc.dispatchPending()) } @Test fun `timeout expires SENT request`() { val req = StubReqTrack(clock) val day = LocalDate.of(2026, 9, 6) val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER) req.linkCoutmsgs(reqId, 10L, 99L) req.markSent(reqId, clock.instant()) clock.advance(Duration.ofMinutes(10).toMillis()) val svc = service(req, StubCoutmsgOutbox()) assertEquals(1, svc.expireTimedOut()) assertEquals(ReqTrackRepository.ReqState.EXPIRED, req.rows[reqId]!!.state) } @Test fun `EROR fails matching open outbound by seqn`() { val req = StubReqTrack(clock) val day = LocalDate.of(2026, 9, 6) val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER) req.linkCoutmsgs(reqId, 1L, 42L) req.markSent(reqId, clock.instant()) val svc = service(req, StubCoutmsgOutbox()) assertTrue(svc.failFromEror(42L, "RQFD", "NONE")) assertEquals(ReqTrackRepository.ReqState.FAILED, req.rows[reqId]!!.state) } @Test fun `refdata sync registers AIRL and dispatches RQRD into COUTMSGS`() { val req = StubReqTrack(clock) val outbox = StubCoutmsgOutbox() val sync = com.gzzn.omms.msgexchange.ingress.RefdataSyncService(service(req, outbox)) val outcome = sync.trigger("AIRL", null) assertTrue(outcome is com.gzzn.omms.msgexchange.ingress.RefdataSyncService.Outcome.Registered) val xml = outbox.messages.values.first() assertTrue(xml.contains("RQRD")) assertTrue(xml.contains("AIRL")) } @Test fun `refdata sync writes RTYP only for RSTA`() { val req = StubReqTrack(clock) val outbox = StubCoutmsgOutbox() val svc = service(req, outbox) svc.registerRqrdSync(com.gzzn.omms.msgexchange.domain.RqrdTarget("RSTA", "GATE")) val xml = outbox.messages.values.first() assertTrue(xml.contains("RSTA")) assertTrue(xml.contains("GATE")) } @Test fun `refdata sync rejects unknown styp and rtyp misuse before registration`() { val req = StubReqTrack(clock) val sync = com.gzzn.omms.msgexchange.ingress.RefdataSyncService(service(req, StubCoutmsgOutbox())) assertEquals( com.gzzn.omms.msgexchange.ingress.RefdataSyncService.Outcome.UnknownStyp, sync.trigger("NOPE", null), ) assertEquals( com.gzzn.omms.msgexchange.ingress.RefdataSyncService.Outcome.MissingRtyp, sync.trigger("RSTA", null), ) assertEquals( com.gzzn.omms.msgexchange.ingress.RefdataSyncService.Outcome.UnexpectedRtyp, sync.trigger("AIRL", "GATE"), ) assertTrue(req.rows.isEmpty()) } @Test fun `open slot rejects duplicate refdata sync registration`() { val req = StubReqTrack(clock) val sync = com.gzzn.omms.msgexchange.ingress.RefdataSyncService(service(req, StubCoutmsgOutbox())) sync.trigger("AIRL", null) assertEquals( com.gzzn.omms.msgexchange.ingress.RefdataSyncService.Outcome.OpenExists, sync.trigger("AIRL", null), ) } @Test fun `RQRD and RQFD use separate open slots`() { val req = StubReqTrack(clock) val rqrd = com.gzzn.omms.msgexchange.ingress.RefdataSyncService(service(req, StubCoutmsgOutbox())) val rqfd = SchdSyncService(service(req, StubCoutmsgOutbox())) rqrd.trigger("AIRL", null) assertTrue(rqfd.trigger() is SchdSyncService.Outcome.Registered) } }