task-135: 任务状态成功时机契约(SUCCESS 必在文件生成上传后、无文件不成功、fileReady/downloadUrl 语义不变)+ 8 条守门测试

This commit is contained in:
2026-09-02 05:03:05 +08:00
parent c8d14a5b1b
commit a982da0262
@@ -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<FileTaskEntity> 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<FileResultEntity> 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<FileResultEntity> 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;
}
}