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