diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultSuccessTimingContractTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultSuccessTimingContractTest.java new file mode 100644 index 00000000..9e875f99 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultSuccessTimingContractTest.java @@ -0,0 +1,184 @@ +package com.nanri.aiimage.modules.task.contract; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.appearancepatent.controller.AppearancePatentController; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSubmitResultRequest; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.collectdata.controller.CollectDataController; +import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSubmitResultRequest; +import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataSubmitResultVo; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.publish.controller.PublishController; +import com.nanri.aiimage.modules.publish.model.dto.PublishSubmitResultRequest; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.similarasin.controller.SimilarAsinController; +import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSubmitResultRequest; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * task-132:/result 成功确认时机硬约束契约(spec 07 §2)。 + * HTTP 200 + success=true 必须在结果分片可靠落库(service.submitResult 正常返回) + * 之后;落库失败不返回成功;失败后重试安全。四个模块端点统一守门。 + */ +@ExtendWith(MockitoExtension.class) +class ResultSuccessTimingContractTest { + + private static final Long TASK_ID = 8888L; + + @Mock private SimilarAsinTaskService similarAsinTaskService; + @Mock private AppearancePatentTaskService appearancePatentTaskService; + @Mock private CollectDataService collectDataService; + @Mock private PublishTaskService publishTaskService; + + @Test + void similarasinResultReturnsSuccessOnlyAfterServiceReturns() { + AtomicBoolean serviceDone = new AtomicBoolean(false); + AtomicBoolean responseAfterService = new AtomicBoolean(false); + doAnswer(invocation -> { + serviceDone.set(true); + return null; + }).when(similarAsinTaskService).submitResult(eq(TASK_ID), any(SimilarAsinSubmitResultRequest.class)); + + ApiResponse response = new SimilarAsinController(similarAsinTaskService) + .result(TASK_ID, new SimilarAsinSubmitResultRequest(), mock(HttpServletResponse.class)); + + responseAfterService.set(response != null); + assertTrue(serviceDone.get(), "service.submitResult 必须先于响应返回"); + assertTrue(responseAfterService.get()); + assertTrue(response.isSuccess(), "success=true"); + assertEquals(200, response.getCode() == null ? 200 : response.getCode()); + } + + @Test + void appearancePatentResultReturnsSuccessAfterPersist() { + doAnswer(invocation -> null) + .when(appearancePatentTaskService).submitResult(eq(TASK_ID), any(AppearancePatentSubmitResultRequest.class)); + + ApiResponse response = new AppearancePatentController(appearancePatentTaskService) + .result(TASK_ID, new AppearancePatentSubmitResultRequest(), mock(HttpServletResponse.class)); + + assertTrue(response.isSuccess(), "success=true"); + verify(appearancePatentTaskService).submitResult(eq(TASK_ID), any(AppearancePatentSubmitResultRequest.class)); + } + + @Test + void collectDataResultReturnsVoWithSuccessFlag() { + CollectDataSubmitResultVo vo = new CollectDataSubmitResultVo(); + vo.setTaskId(TASK_ID); + vo.setChunkIndex(1); + when(collectDataService.submitResult(eq(TASK_ID), any(CollectDataSubmitResultRequest.class))).thenReturn(vo); + + ApiResponse response = new CollectDataController(collectDataService) + .submitResult(TASK_ID, new CollectDataSubmitResultRequest()); + + assertTrue(response.isSuccess(), "success=true"); + assertEquals(TASK_ID, response.getData().getTaskId(), "落库后返回的 VO 必须携带已提交状态"); + assertNotNull(response.getData()); + } + + @Test + void publishResultReturnsSuccessAfterCommit() { + doAnswer(invocation -> null) + .when(publishTaskService).submitResult(eq(TASK_ID), any(PublishSubmitResultRequest.class)); + + ApiResponse response = new PublishController(publishTaskService) + .submitResult(TASK_ID, new PublishSubmitResultRequest()); + + assertTrue(response.isSuccess(), "success=true"); + verify(publishTaskService).submitResult(eq(TASK_ID), any(PublishSubmitResultRequest.class)); + } + + @Test + void persistFailurePropagatesAndNeverReturnsSuccess() { + org.mockito.Mockito.doThrow(new BusinessException("落库失败")) + .when(similarAsinTaskService).submitResult(eq(TASK_ID), any(SimilarAsinSubmitResultRequest.class)); + + assertThrows(BusinessException.class, () -> new SimilarAsinController(similarAsinTaskService) + .result(TASK_ID, new SimilarAsinSubmitResultRequest(), mock(HttpServletResponse.class))); + } + + @Test + void retryAfterFailureSucceeds() { + AtomicInteger calls = new AtomicInteger(); + doAnswer(invocation -> { + if (calls.incrementAndGet() == 1) { + throw new BusinessException("首次落库失败"); + } + return null; + }).when(similarAsinTaskService).submitResult(eq(TASK_ID), any(SimilarAsinSubmitResultRequest.class)); + SimilarAsinController controller = new SimilarAsinController(similarAsinTaskService); + + assertThrows(BusinessException.class, () -> controller + .result(TASK_ID, new SimilarAsinSubmitResultRequest(), mock(HttpServletResponse.class))); + ApiResponse retried = controller + .result(TASK_ID, new SimilarAsinSubmitResultRequest(), mock(HttpServletResponse.class)); + + assertTrue(retried.isSuccess(), "重试成功后必须返回 success=true"); + assertEquals(2, calls.get()); + } + + @Test + void serviceInvocationPrecedesResponseConstruction() { + List order = new java.util.ArrayList<>(); + doAnswer(invocation -> { + order.add("service-submit"); + return null; + }).when(appearancePatentTaskService).submitResult(eq(TASK_ID), any(AppearancePatentSubmitResultRequest.class)); + AppearancePatentController controller = new AppearancePatentController(appearancePatentTaskService); + + ApiResponse response = controller.result(TASK_ID, new AppearancePatentSubmitResultRequest(), + mock(HttpServletResponse.class)); + order.add("response-constructed"); + + assertEquals(List.of("service-submit", "response-constructed"), order, + "落库(service 调用)必须先于响应构造"); + assertTrue(response.isSuccess()); + } + + @Test + void resultEndpointContractIsFrozenAcrossModules() throws Exception { + Class[] controllers = { + SimilarAsinController.class, + AppearancePatentController.class, + CollectDataController.class, + PublishController.class, + }; + for (Class controller : controllers) { + Method result = java.util.Arrays.stream(controller.getMethods()) + .filter(method -> method.getName().equals("result") || method.getName().equals("submitResult")) + .filter(method -> method.isAnnotationPresent(PostMapping.class)) + .findFirst() + .orElseThrow(() -> new AssertionError(controller.getSimpleName() + " 缺少 /result 端点")); + PostMapping post = result.getAnnotation(PostMapping.class); + assertTrue(List.of(post.value()).contains("/tasks/{taskId}/result"), + controller.getSimpleName() + " 端点路径必须为 /tasks/{taskId}/result"); + assertNotNull(controller.getAnnotation(RequestMapping.class), + controller.getSimpleName() + " 必须有 @RequestMapping 基路径"); + } + } +}