The per-aggregate FIFO gate (I4) lives entirely in the {@link
* OutboxClaimRepository#claimEligible} native SQL query via a {@code NOT EXISTS} correlated
* subquery. The adapter itself performs NO in-memory filtering: every row returned by the claim
* repository passes through to the caller. Verifying that the SQL gate enforces FIFO is the
* responsibility of the Testcontainers contract test in {@code app-bootstrap}.
*
*
Real SKIP LOCKED / FIFO gate behaviour is covered by the Task E PG contract tests.
*/
class OutboxStoreAdapterTest {
private static final Instant NOW = Instant.parse("2026-06-11T10:00:00Z");
private static final Duration IN_FLIGHT_TIMEOUT = Duration.ofMinutes(5);
private final OutboxEventJpaRepository repo = mock(OutboxEventJpaRepository.class);
private final OutboxClaimRepository claimRepo = mock(OutboxClaimRepository.class);
private final OutboxStoreAdapter adapter = new OutboxStoreAdapter(repo, claimRepo);
// ---- helper ----
private static NewOutboxEvent newEvent(String eventId) {
return new NewOutboxEvent(eventId, "UserCreated", "agg-1", "{}", NOW, "corr-1", "idem-1");
}
private static OutboxEventEntity pendingEntity(String eventId) {
OutboxEventEntity e = new OutboxEventEntity();
e.setEventId(eventId);
e.setAggregateId("agg-1");
e.setEventType("UserCreated");
e.setPayload("{}");
e.setOccurredAt(NOW.minusSeconds(60));
e.setStatus("PENDING");
e.setAttemptCount(0);
e.setNextAttemptAt(NOW.minusSeconds(60));
e.setCorrelationId("corr-1");
e.setIdempotencyKey("idem-1");
return e;
}
// ---- append ----
@Test
void appendPersistsEntityWithCorrectFieldMapping() {
NewOutboxEvent event = newEvent("evt-001");
adapter.append(event);
ArgumentCaptor saved = forClass(OutboxEventEntity.class);
verify(repo).save(saved.capture());
OutboxEventEntity entity = saved.getValue();
assertThat(entity.getEventId()).isEqualTo("evt-001");
assertThat(entity.getEventType()).isEqualTo("UserCreated");
assertThat(entity.getAggregateId()).isEqualTo("agg-1");
assertThat(entity.getPayload()).isEqualTo("{}");
assertThat(entity.getOccurredAt()).isEqualTo(NOW);
assertThat(entity.getStatus()).isEqualTo("PENDING");
assertThat(entity.getAttemptCount()).isEqualTo(0);
assertThat(entity.getNextAttemptAt()).isEqualTo(NOW); // nextAttemptAt = occurredAt on PENDING
assertThat(entity.getCorrelationId()).isEqualTo("corr-1");
assertThat(entity.getIdempotencyKey()).isEqualTo("idem-1");
}
// ---- claimBatch ----
@Test
void claimBatchTransitionsEntityToInFlightAndReturnsMappedEvent() {
OutboxEventEntity entity = pendingEntity("evt-002");
when(claimRepo.claimEligible(eq(NOW), eq(2))).thenReturn(List.of(entity));
List claimed = adapter.claimBatch(2, NOW, IN_FLIGHT_TIMEOUT);
// status and attemptCount updated on entity
assertThat(entity.getStatus()).isEqualTo("IN_FLIGHT");
assertThat(entity.getAttemptCount()).isEqualTo(1);
assertThat(entity.getNextAttemptAt()).isEqualTo(NOW.plus(IN_FLIGHT_TIMEOUT));
// returned OutboxEvent reflects post-transition state
assertThat(claimed).hasSize(1);
OutboxEvent result = claimed.get(0);
assertThat(result.eventId()).isEqualTo("evt-002");
assertThat(result.status()).isEqualTo(OutboxEventStatus.IN_FLIGHT);
assertThat(result.attemptCount()).isEqualTo(1);
}
@Test
void claimBatchReturnsEmptyWhenRepoReturnsNothing() {
when(claimRepo.claimEligible(any(), anyInt())).thenReturn(List.of());
assertThat(adapter.claimBatch(5, NOW, IN_FLIGHT_TIMEOUT)).isEmpty();
}
/**
* Regression guard: the per-aggregate FIFO gate lives in the SQL query (I4). The adapter must NOT
* apply any in-memory filtering — all rows returned by the repository must appear in the result,
* including multiple rows for the same aggregate.
*
*
If this test fails it means in-memory filtering was re-introduced in the adapter; the fix is
* to move that logic back to the {@code claimEligible} SQL query.
*/
@Test
void claimBatchPassesAllRepoResultsThroughWithoutInMemoryFifoFiltering() {
// Two rows for the same aggregate — repo returns both (SQL gate already enforces FIFO)
OutboxEventEntity head = pendingEntity("evt-head");
OutboxEventEntity tail = new OutboxEventEntity();
tail.setEventId("evt-tail");
tail.setAggregateId("agg-1"); // same aggregate as head
tail.setEventType("UserCreated");
tail.setPayload("{}");
tail.setOccurredAt(NOW.minusSeconds(30)); // later than head (head is at -60s)
tail.setStatus("PENDING");
tail.setAttemptCount(0);
tail.setNextAttemptAt(NOW.minusSeconds(30));
tail.setCorrelationId("corr-2");
tail.setIdempotencyKey("idem-2");
when(claimRepo.claimEligible(eq(NOW), eq(10))).thenReturn(List.of(head, tail));
List claimed = adapter.claimBatch(10, NOW, IN_FLIGHT_TIMEOUT);
// Adapter must return both rows — no in-memory FIFO filtering
assertThat(claimed)
.hasSize(2)
.extracting(OutboxEvent::eventId)
.containsExactly("evt-head", "evt-tail");
}
// ---- markPublished ----
@Test
void markPublishedUpdatesStatusToPublished() {
OutboxEventEntity entity = pendingEntity("evt-003");
entity.setStatus("IN_FLIGHT");
when(repo.findById("evt-003")).thenReturn(Optional.of(entity));
adapter.markPublished("evt-003");
assertThat(entity.getStatus()).isEqualTo("PUBLISHED");
}
// ---- markFailed ----
@Test
void markFailedSetsFailedStatusAndNextAttemptAt() {
OutboxEventEntity entity = pendingEntity("evt-004");
entity.setStatus("IN_FLIGHT");
when(repo.findById("evt-004")).thenReturn(Optional.of(entity));
Instant retryAt = NOW.plus(Duration.ofSeconds(30));
adapter.markFailed("evt-004", retryAt);
assertThat(entity.getStatus()).isEqualTo("FAILED");
assertThat(entity.getNextAttemptAt()).isEqualTo(retryAt);
}
// ---- markDead ----
@Test
void markDeadSetsDeadStatus() {
OutboxEventEntity entity = pendingEntity("evt-005");
entity.setStatus("IN_FLIGHT");
when(repo.findById("evt-005")).thenReturn(Optional.of(entity));
adapter.markDead("evt-005");
assertThat(entity.getStatus()).isEqualTo("DEAD");
}
// ---- countByStatus ----
@Test
void countByStatusMapsProjectionResultsToStatusEnumMap() {
List