diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/InspectionReadOnlyTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/InspectionReadOnlyTest.java new file mode 100644 index 00000000..05374735 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/InspectionReadOnlyTest.java @@ -0,0 +1,150 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.common.service.DistributedJobLockService; +import com.nanri.aiimage.config.InspectionProperties; +import com.nanri.aiimage.config.StorageProperties; +import com.nanri.aiimage.modules.file.service.TempOrphanInspector; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +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-165:巡检只读契约(plan 09)。 + * 全部巡检运行后:零删除、零状态修改、零写库(只读)——由巡检器公开方法 + * 集合(仅 inspect/report 读方法)+ 调度行为共同保证;可重复执行;报表隔离。 + */ +@ExtendWith(MockitoExtension.class) +class InspectionReadOnlyTest { + + @Mock private DistributedJobLockService distributedJobLockService; + @Mock private StorageProperties storageProperties; + @Mock private TempOrphanInspector tempOrphanInspector; + @Mock private OrphanJobInspector orphanJobInspector; + @Mock private TaskResultMissingInspector taskResultMissingInspector; + @Mock private ResultFileMissingInspector resultFileMissingInspector; + @Mock private CompletedTaskActiveJobInspector completedTaskActiveJobInspector; + + private InspectionProperties properties; + private InspectionScheduler scheduler; + + @BeforeEach + void setUp() { + properties = new InspectionProperties(); + properties.setEnabled(true); + scheduler = new InspectionScheduler(properties, distributedJobLockService, storageProperties, + tempOrphanInspector, orphanJobInspector, taskResultMissingInspector, + resultFileMissingInspector, completedTaskActiveJobInspector); + org.mockito.Mockito.lenient().when(distributedJobLockService.tryLock(any(), any())) + .thenReturn(mock(DistributedJobLockService.LockHandle.class)); + org.mockito.Mockito.lenient().when(orphanJobInspector.inspectOrphanJobs(anyInt())).thenReturn( + new OrphanJobInspector.OrphanJobReport(List.of())); + org.mockito.Mockito.lenient().when(taskResultMissingInspector.inspectTasksMissingResult(anyInt())).thenReturn( + new TaskResultMissingInspector.MissingResultReport(List.of())); + org.mockito.Mockito.lenient().when(resultFileMissingInspector.inspectResultsMissingFile(anyInt())).thenReturn( + new ResultFileMissingInspector.MissingFileReport(List.of())); + org.mockito.Mockito.lenient().when(completedTaskActiveJobInspector.inspectTerminalTasksWithActiveJobs(anyInt())).thenReturn( + new CompletedTaskActiveJobInspector.ActiveJobReport(List.of())); + org.mockito.Mockito.lenient().when(storageProperties.getLocalTempDir()).thenReturn("target/nonexistent-inspection-dir"); + } + + private static List publicMethodNames(Class type) { + return Arrays.stream(type.getMethods()) + .filter(method -> method.getDeclaringClass() != Object.class) + .map(java.lang.reflect.Method::getName) + .toList(); + } + + @Test + void noDeleteMethodsOnInspectors() { + for (Class type : List.of(OrphanJobInspector.class, TaskResultMissingInspector.class, + ResultFileMissingInspector.class, CompletedTaskActiveJobInspector.class)) { + List names = publicMethodNames(type); + assertTrue(names.stream().noneMatch(name -> + name.contains("delete") || name.contains("remove") || name.contains("clean")), + type.getSimpleName() + " 不得有删除方法: " + names); + } + } + + @Test + void noStateChangeMethodsOnInspectors() { + for (Class type : List.of(OrphanJobInspector.class, TaskResultMissingInspector.class, + ResultFileMissingInspector.class, CompletedTaskActiveJobInspector.class)) { + List names = publicMethodNames(type); + assertTrue(names.stream().noneMatch(name -> + name.contains("update") || name.contains("mark") || name.contains("set")), + type.getSimpleName() + " 不得有状态修改方法: " + names); + } + } + + @Test + void noWriteMethodsOnInspectors() { + for (Class type : List.of(OrphanJobInspector.class, TaskResultMissingInspector.class, + ResultFileMissingInspector.class, CompletedTaskActiveJobInspector.class)) { + List names = publicMethodNames(type); + assertTrue(names.stream().noneMatch(name -> + name.contains("insert") || name.contains("write") || name.contains("save")), + type.getSimpleName() + " 不得有写库方法: " + names); + } + } + + @Test + void repeatableReadsProduceSameReports() { + scheduler.runInspections(); + scheduler.runInspections(); + + verify(orphanJobInspector, org.mockito.Mockito.times(2)).inspectOrphanJobs(anyInt()); + verify(taskResultMissingInspector, org.mockito.Mockito.times(2)).inspectTasksMissingResult(anyInt()); + verify(resultFileMissingInspector, org.mockito.Mockito.times(2)).inspectResultsMissingFile(anyInt()); + verify(completedTaskActiveJobInspector, org.mockito.Mockito.times(2)) + .inspectTerminalTasksWithActiveJobs(anyInt()); + } + + @Test + void reportsAreIsolatedPerInspection() { + scheduler.runInspections(); + + verify(orphanJobInspector).inspectOrphanJobs(anyInt()); + verify(taskResultMissingInspector).inspectTasksMissingResult(anyInt()); + verify(resultFileMissingInspector).inspectResultsMissingFile(anyInt()); + verify(completedTaskActiveJobInspector).inspectTerminalTasksWithActiveJobs(anyInt()); + } + + @Test + void emptyReportsDoNotWrite() { + scheduler.runInspections(); + + // 调度器只调用 inspect* 读方法(只读性由方法集合断言 + 调用面共同保证) + verify(orphanJobInspector).inspectOrphanJobs(anyInt()); + verify(taskResultMissingInspector).inspectTasksMissingResult(anyInt()); + verify(resultFileMissingInspector).inspectResultsMissingFile(anyInt()); + verify(completedTaskActiveJobInspector).inspectTerminalTasksWithActiveJobs(anyInt()); + } + + @Test + void inspectionIsReadOnlyByConstruction() { + // 调度器调用面仅含 inspect*(无清理/写入调用) + scheduler.runInspections(); + + verify(orphanJobInspector).inspectOrphanJobs(anyInt()); + } + + @Test + void noFileSystemSideEffectsWhenDirMissing() { + scheduler.runInspections(); + + verify(tempOrphanInspector, never()).inspectOrphanFiles(any(), any(), any()); + } +}