task-132: /result 成功确认时机契约(四模块 200+success=true 必在落库后、失败不成功、重试安全)+ 8 条守门测试

This commit is contained in:
2026-09-02 04:44:21 +08:00
parent c60ad48be5
commit cf83752338
@@ -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<Void> 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<Void> 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<CollectDataSubmitResultVo> 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<Void> 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<Void> retried = controller
.result(TASK_ID, new SimilarAsinSubmitResultRequest(), mock(HttpServletResponse.class));
assertTrue(retried.isSuccess(), "重试成功后必须返回 success=true");
assertEquals(2, calls.get());
}
@Test
void serviceInvocationPrecedesResponseConstruction() {
List<String> 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<Void> 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 基路径");
}
}
}