This commit is contained in:
2026-08-15 12:33:23 +08:00
parent 3a9558bd1c
commit 0ddffeddc8
14 changed files with 290 additions and 29 deletions
@@ -64,6 +64,54 @@ class LoadingModelBridgeServiceTest {
verify(repository).touchLoadingPlan(40, 3, 7, "admin");
}
@Test
void editingReplayWithEveryPairExistingIsReadOnly() {
var plan = new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 9);
when(repository.loadingPlan(40)).thenReturn(plan);
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
when(repository.byPair(11, 21, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
Link first = link("PENDING", 3, "EDITING", 9, 8);
when(repository.byLoadingPlan(40)).thenReturn(List.of(first));
assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
.containsExactly(first);
verify(repository, never()).preload(anyLong(), anyLong());
verify(repository, never()).insert(any());
verify(repository, never()).touchPreload(anyLong(), anyLong(), anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).touchLoadingPlan(anyLong(), anyInt(), anyLong(), anyString());
}
@Test
void editingReplaySkipsExistingPairAndCreatesOnlyMissingPair() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
when(repository.byPair(11, 21, 40)).thenReturn(null);
when(repository.preload(11, 21)).thenReturn(new PreloadSnapshot(31L, 11L, 21L, "PENDING", 5));
when(repository.loadingPlan(40)).thenReturn(
new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3),
new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedPackages(11, 21, 40)).thenReturn(4);
when(repository.touchPreload(31, 11, 21, 5, 7, "admin")).thenReturn(1);
when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1);
Link linked = link("PENDING", 6, "EDITING", 4, 4);
when(repository.byInbound(11, 21)).thenReturn(List.of(linked));
when(repository.byLoadingPlan(40)).thenReturn(List.of(linked));
assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
.containsExactly(linked);
verify(repository, never()).preload(10, 20);
verify(repository, times(2)).preload(11, 21);
verify(repository, times(1)).insert(any());
verify(repository, times(1)).touchPreload(31, 11, 21, 5, 7, "admin");
verify(repository, times(1)).touchLoadingPlan(40, 3, 7, "admin");
}
@Test
void refusesPostHocBindingAfterLoadingStarts() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
@@ -104,6 +152,20 @@ class LoadingModelBridgeServiceTest {
verify(repository, never()).byInbound(10, 20);
}
@Test
void publicBindSameKeyWithChangedVersionsStillConflicts() {
var prior = new PreloadLoadingPlanLinkEntity();
prior.setRequestHash("prior-request-hash");
when(repository.byIdempotency(7, "manual-key")).thenReturn(prior);
assertThatThrownBy(() -> service.bind(10, 20, 40, 7, "admin", "manual-key",
new BindRequest(3, 4)))
.hasMessageContaining("Idempotency-Key已用于不同关联请求");
verify(repository, never()).byPair(anyLong(), anyLong(), anyLong());
verify(repository, never()).insert(any());
}
@Test
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
when(repository.byInbound(10, 20)).thenReturn(List.of(
@@ -16,11 +16,13 @@ class LoadingDeliveryOpenApiContractTest {
assertThat(yaml)
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/pda:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:")
.contains("required: [expectedVersion, evidenceType, fileId]")
.contains("previewUrl:")
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
.contains("同类型可多次追加")
.contains("Required when evidenceType is SEAL")
@@ -30,6 +30,11 @@ class PlatformLoadingDeliveryControllerContractTest {
"platform:firstleg:pda:work", UpsertEvidenceRequest.class);
get("pdaEvidence", "/{inboundId}/loading/evidence/pda",
"platform:firstleg:pda:work");
Method pdaContent = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaEvidenceContent",
AuthenticatedUser.class, long.class, long.class, long.class);
assertThat(pdaContent.getAnnotation(GetMapping.class).value())
.containsExactly("/{inboundId}/loading/evidence/{evidenceId}/content/pda");
assertAuthority(pdaContent, "platform:firstleg:pda:work");
Method pdaVoid = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaVoidEvidence",
AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class);
assertThat(pdaVoid.getAnnotation(PostMapping.class).value())
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
@@ -54,6 +55,24 @@ class LoadingDeliveryServiceTest {
token.set(service, "token");
}
@Test
void returnsAuthenticatedPreviewUrlAndProxiesOwnedEvidenceContent() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingEvidenceEntity target = evidence("PHOTO_STEP_1", 11L, null);
target.setId(99L); target.setInboundId(20L); target.setTenantId(10L); target.setStatus("ACTIVE");
when(repository.listEvidence(10, 20)).thenReturn(List.of(target));
when(files.download("token", 10, 20, 11)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
var listed = service.evidence(10, 20);
var content = service.evidenceContent(10, 20, 99);
assertThat(listed.evidences()).singleElement().satisfies(item ->
assertThat(item.previewUrl()).isEqualTo(
"/api/platform/v1/inbounds/20/loading/evidence/99/content/pda?tenantId=10"));
assertThat(content.getBody()).containsExactly(1, 2, 3);
verify(files).download("token", 10, 20, 11);
}
@Test
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -297,6 +316,7 @@ class LoadingDeliveryServiceTest {
private static LoadingEvidenceEntity evidence(String type, Long fileId, String sealNo) {
LoadingEvidenceEntity value = new LoadingEvidenceEntity();
value.setId(fileId); value.setInboundId(20L); value.setTenantId(10L);
value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo);
return value;
}
@@ -391,6 +391,11 @@ class SmartLoadingServiceTest {
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).push(1, "key", 3, 7, "u");
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD");
assertThat(history.getValue().getAfterSnapshot())
.contains(sha256("PUSH_PRELOAD|1|3"));
var ordered = inOrder(splitPreloadService, loadingModelBridge, loadingPickService);
ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
@@ -398,19 +403,77 @@ class SmartLoadingServiceTest {
}
@Test
void editingPushReplayEnsuresPendingPreloadBeforePda() {
void exactPushReplayIsReadOnly() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
Plan replay = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
assertThat(replay.version()).isEqualTo(4);
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void samePushKeyWithDifferentRequestIsRejectedWithoutWrites() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "key", new PushRequest(4), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(repository, never()).items(anyLong());
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verifyNoPushSideEffects();
}
@Test
void newPushKeyAfterSuccessfulPushIsRejectedWithoutWrites() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("first-key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
service.pushPreload(1, "key", new PushRequest(4), 7, "u");
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "new-key", new PushRequest(3), 7, "u"), BizException.class);
verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
verify(loadingPickService).beginIfReady(99, 7, "u");
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("新的幂等键");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verifyNoPushSideEffects();
}
@Test
void pushKeyUsedByAnotherPlanOperationIsRejectedBeforeWrites() {
LoadingPlanEntity editing = plan("EDITING", 3);
when(repository.plan(1)).thenReturn(editing);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("used-key"))
.thenReturn(history(1, 8L, "REMOVE_ITEM", sha256("DELETE|1|8|3|0")));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "used-key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(repository, never()).items(anyLong());
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verifyNoPushSideEffects();
}
@Test
@@ -490,6 +553,12 @@ class SmartLoadingServiceTest {
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
private void verifyNoPushSideEffects() {
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
private void stubAssignment() {
LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));