This commit is contained in:
2026-08-24 17:48:08 +08:00
parent 633cec7c68
commit 4893542ec4
15 changed files with 410 additions and 59 deletions
@@ -0,0 +1,136 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InboundUnloadCompletedOutboxServiceTest {
private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
private final LastlegOrderMapper orders = mock(LastlegOrderMapper.class);
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private final InboundUnloadCompletedOutboxService service = new InboundUnloadCompletedOutboxService();
@BeforeEach
void setup() {
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", json);
}
@Test
void freezesContainerCompletionSeparatelyFromPhotoUpload() throws Exception {
Instant photoUploadedAt = Instant.parse("2026-08-24T08:00:00Z");
Instant containerCompletedAt = Instant.parse("2026-08-24T09:30:00Z");
service.publishContainerCompleted("100", "booking-1", "MSCU1234567",
containerCompletedAt, photoUploadedAt, 7);
ArgumentCaptor<LastlegEventOutbox> captured = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(captured.capture());
LastlegEventOutbox row = captured.getValue();
EventEnvelope envelope = json.readValue(row.getPayloadJson(), EventEnvelope.class);
OverseasContainerUnloadedV1 payload = json.treeToValue(
envelope.payload(), OverseasContainerUnloadedV1.class);
assertThat(payload.unloadingCompletedAt()).isEqualTo(containerCompletedAt);
assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(photoUploadedAt);
assertThat(row.getEventKey()).isEqualTo(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1");
assertThat(envelope.aggregateId()).isEqualTo("booking-1");
}
@Test
void legacySinglePlanEntryUsesTaskCompletionTime() throws Exception {
InboundPlan plan = new InboundPlan();
plan.setSourceOrderId(11L);
plan.setContainerNo("MSCU1234567");
InboundPdaTask task = new InboundPdaTask();
task.setCustomerId("100");
task.setCompletedAt(LocalDateTime.parse("2026-08-24T09:30:00"));
task.setUnloadPhotoUploadedAt(LocalDateTime.parse("2026-08-24T08:00:00"));
task.setVersion(3);
LastlegOrder order = new LastlegOrder();
order.setSourceContainerBookingId("booking-1");
when(orders.selectById(11L)).thenReturn(order);
service.publish(plan, task, List.of(1L));
ArgumentCaptor<LastlegEventOutbox> captured = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(captured.capture());
EventEnvelope envelope = json.readValue(captured.getValue().getPayloadJson(), EventEnvelope.class);
OverseasContainerUnloadedV1 payload = json.treeToValue(
envelope.payload(), OverseasContainerUnloadedV1.class);
assertThat(payload.unloadingCompletedAt()).isEqualTo(Instant.parse("2026-08-24T09:30:00Z"));
assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(Instant.parse("2026-08-24T08:00:00Z"));
}
@Test
void repeatedContainerCompletionDoesNotPublishAnotherEvent() {
LastlegEventOutbox existing = existing("100", "booking-1", "MSCU1234567");
when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"))
.thenReturn(existing);
service.publishContainerCompleted("100", "booking-1", "MSCU1234567",
Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8);
verify(mapper, never()).insert(any(LastlegEventOutbox.class));
}
@Test
void sameIdempotencyKeyCannotRepresentAnotherPhysicalContainer() {
LastlegEventOutbox existing = existing("100", "booking-1", "MSCU1234567");
when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"))
.thenReturn(existing);
assertThatThrownBy(() -> service.publishContainerCompleted("100", "booking-1", "TGHU7654321",
Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("conflicts with another container");
}
private LastlegEventOutbox existing(String customerId, String bookingId, String containerNo) {
OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1(bookingId, containerNo,
Instant.parse("2026-08-24T09:30:00Z"), Instant.parse("2026-08-24T08:00:00Z"), null);
EventEnvelope envelope = new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",
EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-24T09:30:01Z"),
com.baiyue.common.event.RegionCode.OVERSEAS, com.baiyue.common.event.RegionCode.CN,
customerId, "CONTAINER_BOOKING", bookingId, 7, 1L,
"123e4567-e89b-12d3-a456-426614174001",
EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + bookingId, null,
json.valueToTree(payload), "sha256:" + "0".repeat(64));
LastlegEventOutbox row = new LastlegEventOutbox();
row.setCustomerId(customerId);
row.setAggregateId(bookingId);
row.setPayloadJson(write(envelope));
return row;
}
private String write(Object value) {
try {
return json.writeValueAsString(value);
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
}