task-132: /result 成功确认时机契约(四模块 200+success=true 必在落库后、失败不成功、重试安全)+ 8 条守门测试
This commit is contained in:
+184
@@ -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 基路径");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user