From a982da026264a7fe1c5b8f6a72c7b1d9547fbfed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Wed, 2 Sep 2026 05:03:05 +0800 Subject: [PATCH] =?UTF-8?q?task-135:=20=E4=BB=BB=E5=8A=A1=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=88=90=E5=8A=9F=E6=97=B6=E6=9C=BA=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=EF=BC=88SUCCESS=20=E5=BF=85=E5=9C=A8=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=B8=8A=E4=BC=A0=E5=90=8E=E3=80=81=E6=97=A0?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8D=E6=88=90=E5=8A=9F=E3=80=81fileReady?= =?UTF-8?q?/downloadUrl=20=E8=AF=AD=E4=B9=89=E4=B8=8D=E5=8F=98=EF=BC=89+?= =?UTF-8?q?=208=20=E6=9D=A1=E5=AE=88=E9=97=A8=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contract/SuccessTimingContractTest.java | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/SuccessTimingContractTest.java diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/SuccessTimingContractTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/SuccessTimingContractTest.java new file mode 100644 index 00000000..44ca8138 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/SuccessTimingContractTest.java @@ -0,0 +1,231 @@ +package com.nanri.aiimage.modules.task.contract; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.modules.collectdata.mapper.CollectDataCountryPrefMapper; +import com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper; +import com.nanri.aiimage.modules.collectdata.service.CollectDataExcelAssemblyService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.collectdata.util.CollectDataBatchQuery; +import com.nanri.aiimage.modules.collectdata.util.CollectDataBrandBatchFilter; +import com.nanri.aiimage.modules.collectdata.util.CollectDataInvalidAsinBatchWriter; +import com.nanri.aiimage.modules.collectdata.util.CollectDataResultItemBatchWriter; +import com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailReader; +import com.nanri.aiimage.modules.file.service.LocalFileStorageService; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper; +import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper; +import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskDistributedLockService; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; +import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; + +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.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * task-135:任务状态成功时机硬约束契约(spec 07 §2)。 + * 任务 SUCCESS 必须发生在结果文件生成并上传之后;无文件不成功; + * fileReady = resultFileUrl 非空;downloadUrl = 上传 objectKey。collectdata 路径守门。 + */ +@ExtendWith(MockitoExtension.class) +class SuccessTimingContractTest { + + private static final Long TASK_ID = 6868L; + private static final Long RESULT_ID = 6869L; + + @Mock private LocalFileStorageService localFileStorageService; + @Mock private FileTaskMapper fileTaskMapper; + @Mock private FileResultMapper fileResultMapper; + @Mock private CollectDataItemMapper collectDataItemMapper; + @Mock private CollectDataCountryPrefMapper collectDataCountryPrefMapper; + @Mock private InvalidAsinDataMapper invalidAsinDataMapper; + @Mock private TaskChunkMapper taskChunkMapper; + @Mock private TaskScopeStateMapper taskScopeStateMapper; + @Mock private TaskResultItemMapper taskResultItemMapper; + @Mock private TaskDistributedLockService taskDistributedLockService; + @Mock private TaskFileJobService taskFileJobService; + @Mock private TransientPayloadStorageService transientPayloadStorageService; + @Mock private CollectDataExcelAssemblyService excelAssemblyService; + @Mock private OssStorageService ossStorageService; + @Mock private ObjectMapper objectMapper; + @Mock private TransactionTemplate transactionTemplate; + @Mock private CollectDataBatchQuery collectDataBatchQuery; + @Mock private CollectDataBrandBatchFilter brandBatchFilter; + @Mock private CollectDataInvalidAsinBatchWriter invalidAsinBatchWriter; + @Mock private CollectDataResultItemBatchWriter resultItemBatchWriter; + @Mock private CollectDataResultDetailReader resultDetailReader; + + @InjectMocks private CollectDataService service; + + @BeforeAll + static void initializeMybatisMetadata() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class); + TableInfoHelper.initTableInfo(assistant, FileResultEntity.class); + TableInfoHelper.initTableInfo(assistant, TaskFileJobEntity.class); + } + + @BeforeEach + void setUp() { + lenient().when(fileTaskMapper.selectById(TASK_ID)).thenReturn(runningTask()); + lenient().when(fileResultMapper.selectById(RESULT_ID)).thenReturn(runningResult()); + lenient().when(taskResultItemMapper.selectList(any())).thenReturn(List.of()); + lenient().when(ossStorageService.uploadResultFile(any(), eq("COLLECT_DATA"))) + .thenReturn("oss://shufuai/collect-data/6868.xlsx"); + lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenReturn(1); + lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1); + } + + private TaskFileJobEntity job() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setId(901L); + job.setTaskId(TASK_ID); + job.setResultId(RESULT_ID); + job.setModuleType("COLLECT_DATA"); + job.setJobType("ASSEMBLE_RESULT"); + job.setStatus("SUCCESS"); + return job; + } + + @Test + void successHappensOnlyAfterFileGeneratedAndUploaded() { + service.processResultFileJob(job()); + + var order = inOrder(ossStorageService, fileResultMapper, fileTaskMapper); + order.verify(ossStorageService).uploadResultFile(any(), eq("COLLECT_DATA")); + order.verify(fileResultMapper).updateById(any(FileResultEntity.class)); + order.verify(fileTaskMapper).updateById(any(FileTaskEntity.class)); + } + + @Test + void taskStatusBecomesSuccessWithFileFields() { + service.processResultFileJob(job()); + + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(FileTaskEntity.class); + verify(fileTaskMapper).updateById(taskCaptor.capture()); + FileTaskEntity updated = taskCaptor.getValue(); + assertEquals("SUCCESS", updated.getStatus(), "任务状态成功时机 = 结果文件生成后"); + assertEquals(1, updated.getSuccessFileCount()); + assertEquals(0, updated.getFailedFileCount()); + assertNotNull(updated.getFinishedAt()); + } + + @Test + void resultCarriesDownloadUrlAndReadyState() { + service.processResultFileJob(job()); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(FileResultEntity.class); + verify(fileResultMapper).updateById(resultCaptor.capture()); + FileResultEntity result = resultCaptor.getValue(); + assertEquals("oss://shufuai/collect-data/6868.xlsx", result.getResultFileUrl(), + "downloadUrl = 上传后的 objectKey(含义不变)"); + assertEquals("采集-6868-result.xlsx", result.getResultFilename()); + assertEquals(1, result.getSuccess()); + assertTrue(result.getResultFileUrl() != null && !result.getResultFileUrl().isBlank(), + "fileReady 语义:url 非空即可下载"); + } + + @Test + void fileReadyDerivedFromResultFileUrl() { + service.processResultFileJob(job()); + + // fileReady 由 url 推导(TaskProgressLightAssembler 语义),成功时机与 url 落库绑定 + verify(fileResultMapper).updateById(any(FileResultEntity.class)); + verify(fileTaskMapper).updateById(any(FileTaskEntity.class)); + } + + @Test + void noFileGeneratedNoSuccess() { + doThrow(new IllegalStateException("工作簿生成失败")) + .when(excelAssemblyService).writeWorkbookSegmented(any(), any(), any(), any()); + + assertThrows(IllegalStateException.class, () -> service.processResultFileJob(job())); + + verify(fileTaskMapper, never()).updateById(any(FileTaskEntity.class)); + verify(ossStorageService, never()).uploadResultFile(any(), any()); + } + + @Test + void uploadFailureNoSuccess() { + when(ossStorageService.uploadResultFile(any(), eq("COLLECT_DATA"))) + .thenThrow(new IllegalStateException("上传失败")); + + assertThrows(IllegalStateException.class, () -> service.processResultFileJob(job())); + + verify(fileTaskMapper, never()).updateById(any(FileTaskEntity.class)); + verify(fileResultMapper, never()).updateById(any(FileResultEntity.class)); + } + + @Test + void successObservableThroughResultRow() { + service.processResultFileJob(job()); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(FileResultEntity.class); + verify(fileResultMapper).updateById(resultCaptor.capture()); + assertEquals(1, resultCaptor.getValue().getSuccess(), "result.success=1 供前端观测"); + assertEquals("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + resultCaptor.getValue().getResultContentType()); + } + + @Test + void successContractFrozen() { + service.processResultFileJob(job()); + + // 契约快照:文件生成+上传 → result 落库 → 任务 SUCCESS(一次 updateById 各一) + verify(ossStorageService).uploadResultFile(any(), eq("COLLECT_DATA")); + verify(fileResultMapper).updateById(any(FileResultEntity.class)); + verify(fileTaskMapper).updateById(any(FileTaskEntity.class)); + verify(fileTaskMapper, org.mockito.Mockito.times(1)).updateById(any(FileTaskEntity.class)); + } + + private FileTaskEntity runningTask() { + FileTaskEntity task = new FileTaskEntity(); + task.setId(TASK_ID); + task.setModuleType("COLLECT_DATA"); + task.setStatus("RUNNING"); + task.setUserId(7L); + task.setResultJson("{}"); + return task; + } + + private FileResultEntity runningResult() { + FileResultEntity result = new FileResultEntity(); + result.setId(RESULT_ID); + result.setTaskId(TASK_ID); + result.setModuleType("COLLECT_DATA"); + result.setSourceFilename("采集-6868"); + result.setSuccess(0); + return result; + } +}