feat(wcs): add device command and outbox lifecycle
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
package com.ruoyi.wcs.domain.execution;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public final class CommandOutboxEntry {
|
||||||
|
private final String commandId;
|
||||||
|
private final String commandKey;
|
||||||
|
private final String payload;
|
||||||
|
private OutboxDeliveryState state;
|
||||||
|
private int attemptCount;
|
||||||
|
private Instant nextAttemptAt;
|
||||||
|
private String lastError;
|
||||||
|
|
||||||
|
private CommandOutboxEntry(String commandId, String commandKey,
|
||||||
|
String payload, Instant nextAttemptAt) {
|
||||||
|
this.commandId = requireText(commandId, "commandId");
|
||||||
|
this.commandKey = requireText(commandKey, "commandKey");
|
||||||
|
this.payload = requireText(payload, "payload");
|
||||||
|
this.nextAttemptAt = Objects.requireNonNull(nextAttemptAt, "nextAttemptAt");
|
||||||
|
this.state = OutboxDeliveryState.PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CommandOutboxEntry pending(String commandId, String commandKey,
|
||||||
|
String payload, Instant nextAttemptAt) {
|
||||||
|
return new CommandOutboxEntry(commandId, commandKey, payload, nextAttemptAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCommandId() {
|
||||||
|
return commandId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCommandKey() {
|
||||||
|
return commandKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPayload() {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutboxDeliveryState getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getAttemptCount() {
|
||||||
|
return attemptCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getNextAttemptAt() {
|
||||||
|
return nextAttemptAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLastError() {
|
||||||
|
return lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(String value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(field + " must not be blank");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ruoyi.wcs.domain.execution;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||||
|
|
||||||
|
public final class CommandResolutionEvidence {
|
||||||
|
public enum Type {
|
||||||
|
PHYSICAL_COMPLETION,
|
||||||
|
CONTROLLED_ABORT
|
||||||
|
}
|
||||||
|
|
||||||
|
private final String commandKey;
|
||||||
|
private final String evidenceId;
|
||||||
|
private final Type type;
|
||||||
|
private final String operatorId;
|
||||||
|
|
||||||
|
private CommandResolutionEvidence(String commandKey, String evidenceId,
|
||||||
|
Type type, String operatorId) {
|
||||||
|
this.commandKey = requireText(commandKey, "commandKey");
|
||||||
|
this.evidenceId = requireText(evidenceId, "evidenceId");
|
||||||
|
this.type = java.util.Objects.requireNonNull(type, "type");
|
||||||
|
this.operatorId = operatorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CommandResolutionEvidence physicalCompletion(
|
||||||
|
String commandKey, String evidenceId) {
|
||||||
|
return new CommandResolutionEvidence(commandKey, evidenceId,
|
||||||
|
Type.PHYSICAL_COMPLETION, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CommandResolutionEvidence controlledAbort(
|
||||||
|
String commandKey, String evidenceId, String operatorId) {
|
||||||
|
return new CommandResolutionEvidence(commandKey, evidenceId,
|
||||||
|
Type.CONTROLLED_ABORT, requireText(operatorId, "operatorId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void requireMatches(String expectedCommandKey, Type expectedType) {
|
||||||
|
if (!commandKey.equals(expectedCommandKey) || type != expectedType) {
|
||||||
|
throw new DomainRuleViolation(
|
||||||
|
"resolution evidence does not match the device command");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEvidenceId() {
|
||||||
|
return evidenceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOperatorId() {
|
||||||
|
return operatorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(String value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(field + " must not be blank");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package com.ruoyi.wcs.domain.execution;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public final class DeviceCommand {
|
||||||
|
private final String commandId;
|
||||||
|
private final String commandKey;
|
||||||
|
private final String deviceId;
|
||||||
|
private final DeviceCommandType type;
|
||||||
|
private final String payload;
|
||||||
|
private final int attempt;
|
||||||
|
private final Instant createdAt;
|
||||||
|
|
||||||
|
private DeviceCommandState state;
|
||||||
|
private String sendReceiptId;
|
||||||
|
private String acknowledgementEvidenceId;
|
||||||
|
private String completionEvidenceId;
|
||||||
|
private String statusReason;
|
||||||
|
private Instant updatedAt;
|
||||||
|
private long stateVersion;
|
||||||
|
|
||||||
|
private DeviceCommand(String commandId, String commandKey, String deviceId,
|
||||||
|
DeviceCommandType type, String payload, int attempt,
|
||||||
|
Instant createdAt) {
|
||||||
|
this.commandId = requireText(commandId, "commandId");
|
||||||
|
this.commandKey = requireText(commandKey, "commandKey");
|
||||||
|
this.deviceId = requireText(deviceId, "deviceId");
|
||||||
|
this.type = Objects.requireNonNull(type, "type");
|
||||||
|
this.payload = requireText(payload, "payload");
|
||||||
|
if (attempt <= 0) {
|
||||||
|
throw new IllegalArgumentException("attempt must be positive");
|
||||||
|
}
|
||||||
|
this.attempt = attempt;
|
||||||
|
this.createdAt = Objects.requireNonNull(createdAt, "createdAt");
|
||||||
|
this.updatedAt = createdAt;
|
||||||
|
this.state = DeviceCommandState.PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DeviceCommand pending(String commandId, String commandKey,
|
||||||
|
String deviceId, DeviceCommandType type,
|
||||||
|
String payload, int attempt,
|
||||||
|
Instant createdAt) {
|
||||||
|
return new DeviceCommand(commandId, commandKey, deviceId, type,
|
||||||
|
payload, attempt, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markSent(String sendReceiptId, Instant at) {
|
||||||
|
requireState(DeviceCommandState.PENDING);
|
||||||
|
Instant transitionAt = requireTransitionTime(at);
|
||||||
|
this.sendReceiptId = requireText(sendReceiptId, "sendReceiptId");
|
||||||
|
transition(DeviceCommandState.SENT, transitionAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void acknowledge(String evidenceId, Instant at) {
|
||||||
|
requireState(DeviceCommandState.SENT);
|
||||||
|
Instant transitionAt = requireTransitionTime(at);
|
||||||
|
this.acknowledgementEvidenceId = requireText(evidenceId, "evidenceId");
|
||||||
|
transition(DeviceCommandState.ACKNOWLEDGED, transitionAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void succeed(String evidenceId, Instant at) {
|
||||||
|
requireState(DeviceCommandState.SENT, DeviceCommandState.ACKNOWLEDGED);
|
||||||
|
Instant transitionAt = requireTransitionTime(at);
|
||||||
|
this.completionEvidenceId = requireText(evidenceId, "evidenceId");
|
||||||
|
this.statusReason = null;
|
||||||
|
transition(DeviceCommandState.SUCCEEDED, transitionAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void succeedAfterRecovery(CommandResolutionEvidence evidence, Instant at) {
|
||||||
|
requireState(DeviceCommandState.UNKNOWN);
|
||||||
|
Objects.requireNonNull(evidence, "evidence")
|
||||||
|
.requireMatches(commandKey,
|
||||||
|
CommandResolutionEvidence.Type.PHYSICAL_COMPLETION);
|
||||||
|
Instant transitionAt = requireTransitionTime(at);
|
||||||
|
this.completionEvidenceId = evidence.getEvidenceId();
|
||||||
|
this.statusReason = null;
|
||||||
|
transition(DeviceCommandState.SUCCEEDED, transitionAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reject(String reason, Instant at) {
|
||||||
|
requireState(DeviceCommandState.SENT);
|
||||||
|
Instant transitionAt = requireTransitionTime(at);
|
||||||
|
this.statusReason = requireText(reason, "reason");
|
||||||
|
transition(DeviceCommandState.REJECTED, transitionAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void failBeforeSend(String reason, Instant at) {
|
||||||
|
requireState(DeviceCommandState.PENDING);
|
||||||
|
Instant transitionAt = requireTransitionTime(at);
|
||||||
|
this.statusReason = requireText(reason, "reason");
|
||||||
|
transition(DeviceCommandState.FAILED, transitionAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void failAfterRecovery(CommandResolutionEvidence evidence,
|
||||||
|
String reason, Instant at) {
|
||||||
|
requireState(DeviceCommandState.UNKNOWN);
|
||||||
|
Objects.requireNonNull(evidence, "evidence")
|
||||||
|
.requireMatches(commandKey,
|
||||||
|
CommandResolutionEvidence.Type.CONTROLLED_ABORT);
|
||||||
|
Instant transitionAt = requireTransitionTime(at);
|
||||||
|
this.statusReason = requireText(reason, "reason");
|
||||||
|
transition(DeviceCommandState.FAILED, transitionAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markUnknown(String reason, Instant at) {
|
||||||
|
requireState(DeviceCommandState.SENT, DeviceCommandState.ACKNOWLEDGED);
|
||||||
|
Instant transitionAt = requireTransitionTime(at);
|
||||||
|
this.statusReason = requireText(reason, "reason");
|
||||||
|
transition(DeviceCommandState.UNKNOWN, transitionAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canSendMotionCommandAfterIt() {
|
||||||
|
return state == DeviceCommandState.SUCCEEDED
|
||||||
|
|| state == DeviceCommandState.REJECTED
|
||||||
|
|| state == DeviceCommandState.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void transition(DeviceCommandState next, Instant at) {
|
||||||
|
this.state = next;
|
||||||
|
this.updatedAt = at;
|
||||||
|
this.stateVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant requireTransitionTime(Instant at) {
|
||||||
|
Instant candidate = Objects.requireNonNull(at, "at");
|
||||||
|
if (candidate.isBefore(updatedAt)) {
|
||||||
|
throw new DomainRuleViolation("device command transition time regressed");
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireState(DeviceCommandState... allowed) {
|
||||||
|
for (DeviceCommandState candidate : allowed) {
|
||||||
|
if (state == candidate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new DomainRuleViolation("illegal device command transition from " + state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCommandId() {
|
||||||
|
return commandId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCommandKey() {
|
||||||
|
return commandKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceId() {
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeviceCommandType getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPayload() {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getAttempt() {
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeviceCommandState getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSendReceiptId() {
|
||||||
|
return sendReceiptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAcknowledgementEvidenceId() {
|
||||||
|
return acknowledgementEvidenceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCompletionEvidenceId() {
|
||||||
|
return completionEvidenceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatusReason() {
|
||||||
|
return statusReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStateVersion() {
|
||||||
|
return stateVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(String value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(field + " must not be blank");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ruoyi.wcs.domain.execution;
|
||||||
|
|
||||||
|
public enum DeviceCommandState {
|
||||||
|
PENDING,
|
||||||
|
SENT,
|
||||||
|
ACKNOWLEDGED,
|
||||||
|
SUCCEEDED,
|
||||||
|
REJECTED,
|
||||||
|
FAILED,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.ruoyi.wcs.domain.execution;
|
||||||
|
|
||||||
|
public enum DeviceCommandType {
|
||||||
|
MOVE_ROUTE,
|
||||||
|
LIFT_TRANSFER,
|
||||||
|
CONVEYOR_TRANSFER,
|
||||||
|
CHARGE
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ruoyi.wcs.domain.execution;
|
||||||
|
|
||||||
|
public enum OutboxDeliveryState {
|
||||||
|
PENDING,
|
||||||
|
DELIVERED,
|
||||||
|
DEAD
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.ruoyi.wcs.domain.execution;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.port.out.WmsNotification;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public final class WmsNotificationOutboxEntry {
|
||||||
|
private final String eventId;
|
||||||
|
private final WmsNotification notification;
|
||||||
|
private final Instant nextAttemptAt;
|
||||||
|
private final OutboxDeliveryState state;
|
||||||
|
|
||||||
|
private WmsNotificationOutboxEntry(String eventId,
|
||||||
|
WmsNotification notification,
|
||||||
|
Instant nextAttemptAt) {
|
||||||
|
this.eventId = requireText(eventId, "eventId");
|
||||||
|
this.notification = Objects.requireNonNull(notification, "notification");
|
||||||
|
if (!this.eventId.equals(notification.getEventId())) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"eventId must match the notification idempotency key");
|
||||||
|
}
|
||||||
|
this.nextAttemptAt = Objects.requireNonNull(nextAttemptAt, "nextAttemptAt");
|
||||||
|
this.state = OutboxDeliveryState.PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WmsNotificationOutboxEntry pending(
|
||||||
|
String eventId, WmsNotification notification, Instant nextAttemptAt) {
|
||||||
|
return new WmsNotificationOutboxEntry(eventId, notification, nextAttemptAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEventId() {
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WmsNotification getNotification() {
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getNextAttemptAt() {
|
||||||
|
return nextAttemptAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutboxDeliveryState getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(String value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(field + " must not be blank");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.ruoyi.wcs.port.out;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.execution.CommandOutboxEntry;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface CommandOutboxRepository {
|
||||||
|
void saveIfAbsent(CommandOutboxEntry entry);
|
||||||
|
|
||||||
|
List<CommandOutboxEntry> findDue(int limit, Instant now);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ruoyi.wcs.port.out;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.execution.DeviceCommand;
|
||||||
|
|
||||||
|
public interface DeviceCommandRepository {
|
||||||
|
DeviceCommand findByCommandKey(String commandKey);
|
||||||
|
|
||||||
|
DeviceCommand findByCommandId(String commandId);
|
||||||
|
|
||||||
|
void save(DeviceCommand command);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.ruoyi.wcs.port.out;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.execution.WmsNotificationOutboxEntry;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface WmsNotificationOutboxRepository {
|
||||||
|
void saveIfAbsent(WmsNotificationOutboxEntry entry);
|
||||||
|
|
||||||
|
List<WmsNotificationOutboxEntry> findDue(int limit, Instant now);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.ruoyi.wcs.domain.execution;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||||
|
import com.ruoyi.wcs.port.out.WmsNotification;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class DeviceCommandTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sentCommandBecomesUnknownInsteadOfRetryableFailure() {
|
||||||
|
DeviceCommand command = pendingCommand();
|
||||||
|
|
||||||
|
command.markSent("SEND-1", at(1));
|
||||||
|
command.markUnknown("ACK_TIMEOUT", at(6));
|
||||||
|
|
||||||
|
assertEquals(DeviceCommandState.UNKNOWN, command.getState());
|
||||||
|
assertEquals("ACK_TIMEOUT", command.getStatusReason());
|
||||||
|
assertFalse(command.canSendMotionCommandAfterIt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownCommandRequiresMatchingPhysicalEvidenceToSucceed() {
|
||||||
|
DeviceCommand command = pendingCommand();
|
||||||
|
command.markSent("SEND-1", at(1));
|
||||||
|
command.markUnknown("ACK_TIMEOUT", at(2));
|
||||||
|
|
||||||
|
assertThrows(DomainRuleViolation.class,
|
||||||
|
() -> command.succeed("FORGED-DONE", at(3)));
|
||||||
|
assertThrows(DomainRuleViolation.class,
|
||||||
|
() -> command.succeedAfterRecovery(
|
||||||
|
CommandResolutionEvidence.physicalCompletion(
|
||||||
|
"OTHER-COMMAND", "DONE-1"), at(3)));
|
||||||
|
|
||||||
|
command.succeedAfterRecovery(
|
||||||
|
CommandResolutionEvidence.physicalCompletion(
|
||||||
|
"CMD-KEY-1", "DONE-1"), at(3));
|
||||||
|
|
||||||
|
assertEquals(DeviceCommandState.SUCCEEDED, command.getState());
|
||||||
|
assertEquals("DONE-1", command.getCompletionEvidenceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void staleTransitionTimeCannotPartiallyMutateTheCommand() {
|
||||||
|
DeviceCommand command = pendingCommand();
|
||||||
|
command.markSent("SEND-1", at(5));
|
||||||
|
|
||||||
|
assertThrows(DomainRuleViolation.class,
|
||||||
|
() -> command.acknowledge("STALE-ACK", at(4)));
|
||||||
|
|
||||||
|
assertEquals(DeviceCommandState.SENT, command.getState());
|
||||||
|
assertNull(command.getAcknowledgementEvidenceId());
|
||||||
|
assertEquals(at(5), command.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void terminalCommandCannotBeAcknowledgedAgain() {
|
||||||
|
DeviceCommand command = pendingCommand();
|
||||||
|
command.markSent("SEND-1", at(1));
|
||||||
|
command.acknowledge("ACK-1", at(2));
|
||||||
|
command.succeed("DONE-1", at(3));
|
||||||
|
|
||||||
|
assertThrows(DomainRuleViolation.class,
|
||||||
|
() -> command.acknowledge("ACK-2", at(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onlyPendingCommandCanFailBeforeSend() {
|
||||||
|
DeviceCommand command = pendingCommand();
|
||||||
|
command.markSent("SEND-1", at(1));
|
||||||
|
|
||||||
|
assertThrows(DomainRuleViolation.class,
|
||||||
|
() -> command.failBeforeSend("SOCKET_CLOSED", at(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void duplicateCommandKeyAndAttemptStayStableAcrossOutboxCreation() {
|
||||||
|
DeviceCommand command = pendingCommand();
|
||||||
|
CommandOutboxEntry outbox = CommandOutboxEntry.pending(
|
||||||
|
command.getCommandId(), command.getCommandKey(),
|
||||||
|
command.getPayload(), at(5));
|
||||||
|
|
||||||
|
assertEquals("CMD-KEY-1", outbox.getCommandKey());
|
||||||
|
assertEquals("CMD-1", outbox.getCommandId());
|
||||||
|
assertEquals(at(5), outbox.getNextAttemptAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wmsOutboxUsesTheBusinessEventAsItsIdempotencyKey() {
|
||||||
|
WmsNotification notification = new WmsNotification(
|
||||||
|
"EVENT-1", "REQ-1", "ORDER-1", at(0),
|
||||||
|
"OrderCompleted", "OK", null,
|
||||||
|
Collections.<String, String>emptyMap());
|
||||||
|
|
||||||
|
WmsNotificationOutboxEntry entry = WmsNotificationOutboxEntry.pending(
|
||||||
|
"EVENT-1", notification, at(7));
|
||||||
|
|
||||||
|
assertEquals("EVENT-1", entry.getEventId());
|
||||||
|
assertEquals("EVENT-1", entry.getNotification().getEventId());
|
||||||
|
assertEquals(at(7), entry.getNextAttemptAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeviceCommand pendingCommand() {
|
||||||
|
return DeviceCommand.pending("CMD-1", "CMD-KEY-1", "V1",
|
||||||
|
DeviceCommandType.MOVE_ROUTE, "{\"route\":[\"A\",\"B\"]}",
|
||||||
|
1, at(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Instant at(long seconds) {
|
||||||
|
return Instant.EPOCH.plusSeconds(seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user