task-98: 移除 similar-asin/appearance-patent 模块 Coze,状态机与共享组件改名 LLM
Build Backend JAR / build (push) Has been cancelled

- similarasin/appearancepatent 模块全部 Coze 工作流调用改走 direct-LLM(已确认唯一运行路径)
- 共享组件改名:CozeTaskQueueGate→TaskQueueGate、CozeGroupResultPropagator→GroupResultPropagator
- 状态机改名:biz_task_scope_state 的 coze_* 列→llm_*、stateJson coze 键→llm(V100 迁移已应用生产)
- 删除 biz_coze_credential 表、CozeCredential* 类、SimilarAsinCozeClient、AppearancePatentCozeClient→LlmClient
- 前端 brand 页 Coze 文案→LLM;Python 后端删除 cozepy 依赖与死配置
- 修复 TaskResultFileJobWorker 启动失败:TaskFileJobConfig 注册 ResultFileJobHandlerRegistry 与 13 个 handler bean(含 validateCoverage 启动校验)
This commit is contained in:
2026-09-01 02:19:45 +08:00
parent 0cc7380205
commit e2607ab723
109 changed files with 6903 additions and 4582 deletions
@@ -0,0 +1,79 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* AppearancePatentResultFileJobHandler 测试(任务 68,异步+offload)。
* process 返回 Service 的 booleanfalse=等待异步结果);cleanup→cleanupResultFileJob
* supportsAsyncOffload()=trueisOwnerScoped()=true。
*/
class AppearancePatentResultFileJobHandlerTest {
private final AppearancePatentTaskService appearancePatentTaskService =
mock(AppearancePatentTaskService.class);
private final ResultFileJobHandler handler = new AppearancePatentResultFileJobHandler(
appearancePatentTaskService);
@Test
void moduleType() {
assertEquals("APPEARANCE_PATENT", handler.moduleType());
}
@Test
void processBooleanTrue() {
TaskFileJobEntity job = new TaskFileJobEntity();
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(true);
assertTrue(handler.process(job), "Service true 时 handler 返回 true");
}
@Test
void processBooleanFalse() {
TaskFileJobEntity job = new TaskFileJobEntity();
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(false);
assertFalse(handler.process(job), "Service false(等待异步)时 handler 返回 false");
}
@Test
void cleanupDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
handler.cleanup(job);
org.mockito.Mockito.verify(appearancePatentTaskService).cleanupResultFileJob(job);
}
@Test
void offloadTrue() {
assertTrue(handler.supportsAsyncOffload(), "APPEARANCE_PATENT 支持异步 offload");
}
@Test
void ownerScopedTrue() {
assertTrue(handler.isOwnerScoped(), "APPEARANCE_PATENT 为 owner scoped");
}
@Test
void falseMeansWaiting() {
// false 语义与 Worker defer 对应:process 返回 false 时不 markSuccess,由异步结果回写
TaskFileJobEntity job = new TaskFileJobEntity();
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(false);
assertFalse(handler.process(job));
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(true);
assertTrue(handler.process(job), "异步完成后再次处理返回 true");
}
@Test
void nullJobGuarded() {
when(appearancePatentTaskService.processResultFileJob(null)).thenReturn(true);
assertTrue(handler.process(null));
handler.cleanup(null);
org.mockito.Mockito.verify(appearancePatentTaskService).cleanupResultFileJob(null);
}
}
@@ -0,0 +1,101 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* BrandResultFileJobHandler 测试(任务 71)。
* BRAND 的 resolveResultObjectKey 特例(无 resultId 也走)保留在 Worker 公共路径 resolveResultFileUrl
* Handler 不接管 URL 解析;cleanup 为空(原 cleanupAfterSuccess 无 BRAND 分支,不走 payload.deleteLatest)。
*/
class BrandResultFileJobHandlerTest {
private final BrandTaskService brandTaskService = mock(BrandTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new BrandResultFileJobHandler(
brandTaskService, taskResultPayloadService);
private static String workerSource() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
return new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
}
@Test
void moduleType() {
assertEquals("BRAND", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(brandTaskService).processResultFileJob(job);
}
@Test
void resultUrlBrandSpecial() throws IOException {
// Handler 不接管 URL 解析:无 resolveResultObjectKey / resolveResultFileUrl 方法
for (java.lang.reflect.Method method : handler.getClass().getDeclaredMethods()) {
assertFalse(method.getName().contains("resolve"),
"Handler 不接管 URL 解析:" + method.getName());
}
// 特例仍留在 Worker 公共路径 resolveResultFileUrl(含 BRAND 分支,无 resultId 也走)
String source = workerSource();
assertTrue(source.contains("resolveResultFileUrl"), "Worker 保留 resolveResultFileUrl 公共路径");
assertTrue(source.contains("resolveResultObjectKey"), "Worker 保留 BRAND 的 resolveResultObjectKey 特例");
assertTrue(source.contains("\"BRAND\"") || source.contains("BRAND"),
"Worker resolveResultFileUrl 含 BRAND 分支");
}
@Test
void cleanupNoop() {
// 原 cleanupAfterSuccess 无 BRAND 分支:cleanup 空操作
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("BRAND");
job.setScopeKey("s");
handler.cleanup(job);
verify(taskResultPayloadService, never()).deleteLatest(1L, "BRAND", "s");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(brandTaskService).processResultFileJob(null);
handler.cleanup(null);
}
@Test
void noDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(2L);
job.setModuleType("BRAND");
handler.cleanup(job);
verify(taskResultPayloadService, never()).deleteLatest(2L, "BRAND", null);
}
}
@@ -0,0 +1,76 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* CollectDataResultFileJobHandler 测试(任务 72)。
* cleanup 为空(原 cleanupAfterSuccess 无 COLLECT_DATA 分支)。
*/
class CollectDataResultFileJobHandlerTest {
private final CollectDataService collectDataService = mock(CollectDataService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new CollectDataResultFileJobHandler(collectDataService);
@Test
void moduleType() {
assertEquals("COLLECT_DATA", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(collectDataService).processResultFileJob(job);
}
@Test
void processTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupNoop() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("COLLECT_DATA");
job.setScopeKey("s");
handler.cleanup(job);
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(collectDataService).processResultFileJob(null);
handler.cleanup(null);
}
@Test
void noDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(2L);
job.setModuleType("COLLECT_DATA");
job.setScopeKey("k");
handler.cleanup(job);
verify(taskResultPayloadService, never()).deleteLatest(2L, "COLLECT_DATA", "k");
}
}
@@ -0,0 +1,73 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* DeleteBrandResultFileJobHandler 测试(任务 70)。
*/
class DeleteBrandResultFileJobHandlerTest {
private final DeleteBrandRunService deleteBrandRunService = mock(DeleteBrandRunService.class);
private final ResultFileJobHandler handler = new DeleteBrandResultFileJobHandler(deleteBrandRunService);
@Test
void moduleType() {
assertEquals("DELETE_BRAND", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(deleteBrandRunService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("DELETE_BRAND");
job.setScopeKey("s");
handler.cleanup(job);
verify(deleteBrandRunService).cleanupResultFileJob(job);
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
handler.cleanup(job);
verify(deleteBrandRunService).cleanupResultFileJob(job);
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(deleteBrandRunService).processResultFileJob(null);
handler.cleanup(null);
verify(deleteBrandRunService).cleanupResultFileJob(null);
}
}
@@ -0,0 +1,75 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* PatrolDeleteResultFileJobHandler 测试(任务 67,与 60 同构)。
*/
class PatrolDeleteResultFileJobHandlerTest {
private final PatrolDeleteTaskService patrolDeleteTaskService = mock(PatrolDeleteTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new PatrolDeleteResultFileJobHandler(
patrolDeleteTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("PATROL_DELETE", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(patrolDeleteTaskService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupCallsDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("PATROL_DELETE");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "PATROL_DELETE", "scope-1");
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
job.setModuleType("PATROL_DELETE");
job.setScopeKey("u:99");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(7L, "PATROL_DELETE", "u:99");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void processNullJobGuarded() {
assertTrue(handler.process(null));
verify(patrolDeleteTaskService).processResultFileJob(null);
}
}
@@ -0,0 +1,75 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* PriceTrackResultFileJobHandler 测试(任务 61,与 60 同构)。
*/
class PriceTrackResultFileJobHandlerTest {
private final PriceTrackTaskService priceTrackTaskService = mock(PriceTrackTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new PriceTrackResultFileJobHandler(
priceTrackTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("PRICE_TRACK", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(priceTrackTaskService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupCallsDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("PRICE_TRACK");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "PRICE_TRACK", "scope-1");
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
job.setModuleType("PRICE_TRACK");
job.setScopeKey("u:99");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(7L, "PRICE_TRACK", "u:99");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void processNullJobGuarded() {
assertTrue(handler.process(null));
verify(priceTrackTaskService).processResultFileJob(null);
}
}
@@ -0,0 +1,75 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* ProductRiskResultFileJobHandler 测试(任务 62,与 60/61 同构)。
*/
class ProductRiskResultFileJobHandlerTest {
private final ProductRiskTaskService productRiskTaskService = mock(ProductRiskTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new ProductRiskResultFileJobHandler(
productRiskTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("PRODUCT_RISK_RESOLVE", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(productRiskTaskService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupCallsDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("PRODUCT_RISK_RESOLVE");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "PRODUCT_RISK_RESOLVE", "scope-1");
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
job.setModuleType("PRODUCT_RISK_RESOLVE");
job.setScopeKey("u:99");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(7L, "PRODUCT_RISK_RESOLVE", "u:99");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void processNullJobGuarded() {
assertTrue(handler.process(null));
verify(productRiskTaskService).processResultFileJob(null);
}
}
@@ -0,0 +1,79 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* PublishResultFileJobHandler 测试(任务 63ownerScoped)。
* moduleType 用 PublishTaskService.MODULE_TYPE 常量;
* cleanup 走 publishTaskService.cleanupResultFileJob,不走 payload.deleteLatest
* isOwnerScoped()=true(原 Worker isOwnerScopedJob 含 PUBLISH)。
*/
class PublishResultFileJobHandlerTest {
private final PublishTaskService publishTaskService = mock(PublishTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new PublishResultFileJobHandler(publishTaskService);
@Test
void moduleTypeConstant() {
assertEquals(PublishTaskService.MODULE_TYPE, handler.moduleType(), "moduleType 用常量");
assertEquals("PUBLISH", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(publishTaskService).processResultFileJob(job);
}
@Test
void cleanupDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("PUBLISH");
job.setScopeKey("s");
handler.cleanup(job);
verify(publishTaskService).cleanupResultFileJob(job);
}
@Test
void ownerScopedTrue() {
assertTrue(handler.isOwnerScoped(), "PUBLISH 为 owner scoped");
}
@Test
void processTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupNoDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("PUBLISH");
job.setScopeKey("s");
handler.cleanup(job);
verify(taskResultPayloadService, never()).deleteLatest(1L, "PUBLISH", "s");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(publishTaskService).processResultFileJob(null);
}
}
@@ -0,0 +1,75 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* QueryAsinResultFileJobHandler 测试(任务 64,与 60 同构)。
*/
class QueryAsinResultFileJobHandlerTest {
private final QueryAsinTaskService queryAsinTaskService = mock(QueryAsinTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new QueryAsinResultFileJobHandler(
queryAsinTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("QUERY_ASIN", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(queryAsinTaskService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupCallsDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("QUERY_ASIN");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "QUERY_ASIN", "scope-1");
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
job.setModuleType("QUERY_ASIN");
job.setScopeKey("u:99");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(7L, "QUERY_ASIN", "u:99");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void processNullJobGuarded() {
assertTrue(handler.process(null));
verify(queryAsinTaskService).processResultFileJob(null);
}
}
@@ -0,0 +1,189 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
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.Mockito.mock;
/**
* 模块映射快照测试(任务 78)。
* 与 04 spec §3 映射表一一对应:13 个 moduleType → Handler 类 → 原 Service 方法;
* 新增模块类型未映射即失败(快照严格性)。
*/
class ResultFileJobHandlerMappingSnapshotTest {
private static final List<String> EXPECTED_MODULE_TYPES = List.of(
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN",
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA");
private static ResultFileJobHandlerRegistry buildRegistry() {
TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(mock(ShopMatchTaskService.class), payload),
new PriceTrackResultFileJobHandler(mock(PriceTrackTaskService.class), payload),
new ProductRiskResultFileJobHandler(mock(ProductRiskTaskService.class), payload),
new PublishResultFileJobHandler(mock(PublishTaskService.class)),
new QueryAsinResultFileJobHandler(mock(QueryAsinTaskService.class), payload),
new ShopDataCrawlResultFileJobHandler(mock(ShopDataCrawlTaskService.class), payload),
new WithdrawResultFileJobHandler(mock(WithdrawTaskService.class), payload),
new PatrolDeleteResultFileJobHandler(mock(PatrolDeleteTaskService.class), payload),
new AppearancePatentResultFileJobHandler(mock(AppearancePatentTaskService.class)),
new SimilarAsinResultFileJobHandler(mock(SimilarAsinTaskService.class)),
new DeleteBrandResultFileJobHandler(mock(DeleteBrandRunService.class)),
new BrandResultFileJobHandler(mock(BrandTaskService.class), payload),
new CollectDataResultFileJobHandler(mock(CollectDataService.class)));
return new ResultFileJobHandlerRegistry(handlers);
}
@Test
void snapshotAll13Mapped() {
ResultFileJobHandlerRegistry registry = buildRegistry();
assertEquals(13, registry.moduleTypes().size(), "恰好 13 个模块");
assertEquals(Set.copyOf(EXPECTED_MODULE_TYPES), Set.copyOf(registry.moduleTypes()),
"moduleType 集合与 spec §3 一致");
}
@Test
void snapshotHandlerClasses() {
ResultFileJobHandlerRegistry registry = buildRegistry();
assertTrue(registry.resolve("SHOP_MATCH") instanceof ShopMatchResultFileJobHandler);
assertTrue(registry.resolve("PRICE_TRACK") instanceof PriceTrackResultFileJobHandler);
assertTrue(registry.resolve("PRODUCT_RISK_RESOLVE") instanceof ProductRiskResultFileJobHandler);
assertTrue(registry.resolve("PUBLISH") instanceof PublishResultFileJobHandler);
assertTrue(registry.resolve("QUERY_ASIN") instanceof QueryAsinResultFileJobHandler);
assertTrue(registry.resolve("SHOP_DATA_CRAWL") instanceof ShopDataCrawlResultFileJobHandler);
assertTrue(registry.resolve("WITHDRAW") instanceof WithdrawResultFileJobHandler);
assertTrue(registry.resolve("PATROL_DELETE") instanceof PatrolDeleteResultFileJobHandler);
assertTrue(registry.resolve("APPEARANCE_PATENT") instanceof AppearancePatentResultFileJobHandler);
assertTrue(registry.resolve("SIMILAR_ASIN") instanceof SimilarAsinResultFileJobHandler);
assertTrue(registry.resolve("DELETE_BRAND") instanceof DeleteBrandResultFileJobHandler);
assertTrue(registry.resolve("BRAND") instanceof BrandResultFileJobHandler);
assertTrue(registry.resolve("COLLECT_DATA") instanceof CollectDataResultFileJobHandler);
}
@Test
void snapshotShopMatchDelegate() throws Exception {
Method process = ShopMatchResultFileJobHandler.class.getDeclaredMethod("process", TaskFileJobEntity.class);
assertTrue(process.getReturnType() == boolean.class, "process 返回 boolean");
String source = handlerSource("ShopMatchResultFileJobHandler");
assertTrue(source.contains("shopMatchTaskService.processResultFileJob(job)"), "委托原 Service 方法");
assertTrue(source.contains("taskResultPayloadService.deleteLatest"), "cleanup 走 payload.deleteLatest");
}
@Test
void snapshotAppearanceBoolean() throws Exception {
Method process = AppearancePatentResultFileJobHandler.class.getDeclaredMethod("process", TaskFileJobEntity.class);
assertTrue(process.getReturnType() == boolean.class, "异步模块返回 boolean");
String source = handlerSource("AppearancePatentResultFileJobHandler");
assertTrue(source.contains("return appearancePatentTaskService.processResultFileJob(job)"),
"原样透传 Service boolean");
assertTrue(source.contains("supportsAsyncOffload()") && source.contains("return true;"),
"offload 标记为 true");
}
@Test
void snapshotPublishOwner() {
ResultFileJobHandler handler = buildRegistry().resolve("PUBLISH");
assertTrue(handler.isOwnerScoped(), "PUBLISH owner scoped");
String source = handlerSource("PublishResultFileJobHandler");
assertTrue(source.contains("publishTaskService.cleanupResultFileJob(job)"),
"cleanup 走服务内 cleanupResultFileJob,不走 payload.deleteLatest");
assertFalse(source.contains("taskResultPayloadService"),
"PUBLISH Handler 无 payload 依赖");
}
@Test
void snapshotFailureCallbacks() {
ResultFileJobHandlerRegistry registry = buildRegistry();
String shopDataCrawl = handlerSource("ShopDataCrawlResultFileJobHandler");
assertTrue(shopDataCrawl.contains("handleResultFileJobFailure"), "SHOP_DATA_CRAWL onFailure 回调");
String similar = handlerSource("SimilarAsinResultFileJobHandler");
assertTrue(similar.contains("handleResultFileJobFailure"), "SIMILAR_ASIN onFailure 回调");
String withdraw = handlerSource("WithdrawResultFileJobHandler");
assertFalse(withdraw.contains("handleResultFileJobFailure"), "WITHDRAW 无 onFailure");
String shopMatch = handlerSource("ShopMatchResultFileJobHandler");
assertFalse(shopMatch.contains("handleResultFileJobFailure"), "SHOP_MATCH 无 onFailure");
}
@Test
void snapshotCleanupMapping() {
ResultFileJobHandlerRegistry registry = buildRegistry();
String shopDataCrawl = handlerSource("ShopDataCrawlResultFileJobHandler");
assertTrue(shopDataCrawl.contains("deleteLatest") && shopDataCrawl.contains("cleanupResultFileJob"),
"SHOP_DATA_CRAWL 两处清理");
String withdraw = handlerSource("WithdrawResultFileJobHandler");
assertTrue(withdraw.contains("deleteLatest"), "WITHDRAW cleanup deleteLatest");
String collectData = handlerSource("CollectDataResultFileJobHandler");
assertTrue(!collectData.contains("deleteLatest") && !collectData.contains("cleanupResultFileJob"),
"COLLECT_DATA 无清理");
}
@Test
void snapshotNoExtraModules() {
ResultFileJobHandlerRegistry registry = buildRegistry();
assertEquals(EXPECTED_MODULE_TYPES.size(), registry.moduleTypes().size());
for (String moduleType : registry.moduleTypes()) {
assertTrue(EXPECTED_MODULE_TYPES.contains(moduleType), "无多余映射:" + moduleType);
}
}
@Test
void snapshotMappingFrozen() throws IOException {
ResultFileJobHandlerRegistry registry = buildRegistry();
List<String> snapshot = new ArrayList<>(registry.moduleTypes());
snapshot.sort(Comparator.naturalOrder());
String actual = String.join(",", snapshot);
String expectedFrozen = "APPEARANCE_PATENT,BRAND,COLLECT_DATA,DELETE_BRAND,PATROL_DELETE,PRICE_TRACK,"
+ "PRODUCT_RISK_RESOLVE,PUBLISH,QUERY_ASIN,SHOP_DATA_CRAWL,SHOP_MATCH,SIMILAR_ASIN,WITHDRAW";
assertEquals(expectedFrozen, actual, "模块映射快照冻结");
}
@Test
void snapshotNewModuleFails() {
ResultFileJobHandlerRegistry registry = buildRegistry();
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> registry.resolve("NEW_MODULE"), "新 moduleType 未映射抛错");
assertTrue(ex.getMessage().contains("NEW_MODULE"), "消息含新模块名");
assertTrue(registry.asMap().get("NEW_MODULE") == null, "asMap 无新模块");
}
private static String handlerSource(String simpleName) {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", simpleName + ".java");
try {
return new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("读不到源码:" + source, e);
}
}
}
@@ -0,0 +1,128 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.TaskFileJobConfig;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 启动 fail-fast 校验测试(任务 59)。
* 覆盖:13 个模块全部有 Handler、缺注册失败、错误消息列缺失清单、
* 双注册失败、配置源来自 TaskFileJobConfig、新增模块无 Handler 失败、
* 合法配置通过、校验可重复执行。
*/
class ResultFileJobHandlerRegistryStartupTest {
private static final Set<String> ALL_13 = TaskFileJobConfig.RESULT_FILE_JOB_MODULE_TYPES;
private static ResultFileJobHandler handler(String moduleType) {
return new ResultFileJobHandler() {
@Override
public String moduleType() {
return moduleType;
}
@Override
public boolean process(TaskFileJobEntity job) {
return true;
}
};
}
private static ResultFileJobHandlerRegistry registryOf(Collection<String> types) {
List<ResultFileJobHandler> handlers = new ArrayList<>();
for (String type : types) {
handlers.add(handler(type));
}
return new ResultFileJobHandlerRegistry(handlers);
}
@Test
void startupAllModulesCovered() {
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13),
"13 个模块全部注册时启动校验通过");
}
@Test
void startupMissingHandlerFails() {
Set<String> missingOne = new java.util.HashSet<>(ALL_13);
missingOne.remove("SHOP_MATCH");
ResultFileJobHandlerRegistry registry = registryOf(missingOne);
assertThrows(IllegalStateException.class, () -> registry.validateCoverage(ALL_13),
"缺 SHOP_MATCH Handler 时启动校验失败");
}
@Test
void startupMissingMessageLists() {
Set<String> missingTwo = new java.util.HashSet<>(ALL_13);
missingTwo.remove("SHOP_MATCH");
missingTwo.remove("WITHDRAW");
ResultFileJobHandlerRegistry registry = registryOf(missingTwo);
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> registry.validateCoverage(ALL_13));
assertTrue(ex.getMessage().contains("SHOP_MATCH"), "消息列缺失模块 SHOP_MATCH" + ex.getMessage());
assertTrue(ex.getMessage().contains("WITHDRAW"), "消息列缺失模块 WITHDRAW" + ex.getMessage());
}
@Test
void startupDuplicateFails() {
List<ResultFileJobHandler> handlers = new ArrayList<>();
handlers.add(handler("SHOP_MATCH"));
handlers.add(handler("SHOP_MATCH"));
assertThrows(IllegalStateException.class, () -> new ResultFileJobHandlerRegistry(handlers),
"双注册在构造时即失败");
}
@Test
void startupConfigSource() {
assertEquals(13, ALL_13.size(), "TaskFileJobConfig 枚举 13 个模块");
assertTrue(ALL_13.containsAll(List.of(
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "QUERY_ASIN",
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA")),
"配置源含全部 12 个常量模块");
assertTrue(ALL_13.contains(PublishTaskService.MODULE_TYPE),
"PUBLISH 取自 PublishTaskService.MODULE_TYPE 常量");
}
@Test
void startupNoHandlerNewModule() {
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
Set<String> withNew = new java.util.HashSet<>(ALL_13);
withNew.add("NEW_MODULE_X");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> registry.validateCoverage(withNew));
assertTrue(ex.getMessage().contains("NEW_MODULE_X"), "新增模块无 Handler 失败:" + ex.getMessage());
}
@Test
void startupValidNoError() {
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13), "合法配置启动通过");
assertEquals(ALL_13, registry.moduleTypes(), "注册表模块与配置源一致");
}
@Test
void startupCheckIdempotent() {
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13));
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13), "第二次校验同样通过(无状态残留)");
Set<String> missingOne = new java.util.HashSet<>(ALL_13);
missingOne.remove("BRAND");
ResultFileJobHandlerRegistry partial = registryOf(missingOne);
assertThrows(IllegalStateException.class, () -> partial.validateCoverage(ALL_13));
assertThrows(IllegalStateException.class, () -> partial.validateCoverage(ALL_13),
"缺注册时重复校验稳定抛错");
}
}
@@ -0,0 +1,112 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* ResultFileJobHandlerRegistry 注册表测试(任务 58)。
* 验证:全量收集、已知/未知模块解析、重复 moduleType 抛 IllegalStateException(消息含模块名)、
* moduleTypes 枚举、空注册表行为、大小写敏感。
*/
class ResultFileJobHandlerRegistryTest {
private static ResultFileJobHandler handler(String moduleType) {
return new ResultFileJobHandler() {
@Override
public String moduleType() {
return moduleType;
}
@Override
public boolean process(TaskFileJobEntity job) {
return true;
}
};
}
private static final ResultFileJobHandler SHOP = handler("SHOP_MATCH");
private static final ResultFileJobHandler PRICE = handler("PRICE_TRACK");
private static final ResultFileJobHandler PUBLISH = handler("PUBLISH");
@Test
void registerAllHandlers() {
ResultFileJobHandlerRegistry registry =
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
assertNotNull(registry.resolve("SHOP_MATCH"), "SHOP_MATCH 已注册");
assertNotNull(registry.resolve("PRICE_TRACK"), "PRICE_TRACK 已注册");
assertNotNull(registry.resolve("PUBLISH"), "PUBLISH 已注册");
}
@Test
void resolveExisting() {
ResultFileJobHandlerRegistry registry =
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
assertEquals(SHOP, registry.resolve("SHOP_MATCH"), "解析到同一 handler 实例");
assertEquals(PRICE, registry.resolve("PRICE_TRACK"), "解析到同一 handler 实例");
}
@Test
void resolveUnknownThrows() {
ResultFileJobHandlerRegistry registry =
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
assertThrows(IllegalArgumentException.class, () -> registry.resolve("NOT_EXIST"),
"未知模块抛 IllegalArgumentException");
}
@Test
void duplicateModuleTypeThrows() {
ResultFileJobHandler duplicate = handler("SHOP_MATCH");
assertThrows(IllegalStateException.class,
() -> new ResultFileJobHandlerRegistry(List.of(SHOP, duplicate)),
"重复 moduleType 抛 IllegalStateException");
}
@Test
void duplicateErrorMessageContainsModuleName() {
ResultFileJobHandler duplicate = handler("SHOP_MATCH");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> new ResultFileJobHandlerRegistry(List.of(SHOP, duplicate)));
assertTrue(ex.getMessage().contains("SHOP_MATCH"), "错误消息含模块名:" + ex.getMessage());
assertTrue(ex.getMessage().contains("重复") || ex.getMessage().contains("duplicate"),
"错误消息含重复提示:" + ex.getMessage());
}
@Test
void moduleTypesListed() {
ResultFileJobHandlerRegistry registry =
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
Set<String> types = registry.moduleTypes();
assertEquals(3, types.size(), "枚举全部模块");
assertTrue(types.containsAll(List.of("SHOP_MATCH", "PRICE_TRACK", "PUBLISH")),
"moduleTypes 覆盖全部注册模块");
}
@Test
void emptyRegistryResolveThrows() {
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(List.of());
assertTrue(registry.moduleTypes().isEmpty(), "空注册表 moduleTypes 为空");
assertThrows(IllegalArgumentException.class, () -> registry.resolve("ANY"),
"空注册表解析任何模块都抛异常");
}
@Test
void resolveCaseSensitive() {
ResultFileJobHandlerRegistry registry =
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
assertThrows(IllegalArgumentException.class, () -> registry.resolve("shop_match"),
"小写不命中(大小写敏感)");
assertEquals(SHOP, registry.resolve("SHOP_MATCH"), "精确大小写命中");
Map<String, ResultFileJobHandler> map = registry.asMap();
assertFalse(map.containsKey("shop_match"), "map 键大小写敏感");
}
}
@@ -0,0 +1,127 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* ResultFileJobHandler 接口契约测试(任务 57)。
* 反射校验 6 个方法签名;匿名实现验证默认实现语义:
* cleanup / onFailure 空操作、supportsAsyncOffload / isOwnerScoped 默认 false。
*/
class ResultFileJobHandlerTest {
private static ResultFileJobHandler blankHandler() {
return new ResultFileJobHandler() {
@Override
public String moduleType() {
return "TEST_MODULE";
}
@Override
public boolean process(TaskFileJobEntity job) {
return true;
}
};
}
private static Method methodOf(Class<?> type, String name, Class<?>... params) throws NoSuchMethodException {
return type.getDeclaredMethod(name, params);
}
@Test
void interfaceMethodsPresent() throws Exception {
assertEquals(7, ResultFileJobHandler.class.getDeclaredMethods().length,
"接口方法数量为 7moduleType/process/onSuccess/cleanup/onFailure/supportsAsyncOffload/isOwnerScoped");
assertNotNull(methodOf(ResultFileJobHandler.class, "moduleType"));
assertNotNull(methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class));
assertNotNull(methodOf(ResultFileJobHandler.class, "onSuccess", TaskFileJobEntity.class));
assertNotNull(methodOf(ResultFileJobHandler.class, "cleanup", TaskFileJobEntity.class));
assertNotNull(methodOf(ResultFileJobHandler.class, "onFailure", TaskFileJobEntity.class, String.class));
assertNotNull(methodOf(ResultFileJobHandler.class, "supportsAsyncOffload"));
assertNotNull(methodOf(ResultFileJobHandler.class, "isOwnerScoped"));
}
@Test
void onSuccessDefaultNoop() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "onSuccess", TaskFileJobEntity.class);
assertTrue(method.isDefault(), "onSuccess 为 default 方法");
blankHandler().onSuccess(null);
}
@Test
void cleanupDefaultNoop() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "cleanup", TaskFileJobEntity.class);
assertTrue(method.isDefault(), "cleanup 为 default 方法");
// 默认实现不抛错、无返回值
blankHandler().cleanup(null);
}
@Test
void onFailureDefaultNoop() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "onFailure", TaskFileJobEntity.class, String.class);
assertTrue(method.isDefault(), "onFailure 为 default 方法");
blankHandler().onFailure(null, "any message");
}
@Test
void offloadDefaultFalse() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "supportsAsyncOffload");
assertTrue(method.isDefault(), "supportsAsyncOffload 为 default 方法");
assertFalse(blankHandler().supportsAsyncOffload(), "默认不支持异步 offload");
}
@Test
void ownerScopedDefaultFalse() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "isOwnerScoped");
assertTrue(method.isDefault(), "isOwnerScoped 为 default 方法");
assertFalse(blankHandler().isOwnerScoped(), "默认非 owner scoped");
}
@Test
void processReturnsBoolean() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class);
assertEquals(boolean.class, method.getReturnType(), "process 返回 boolean");
assertTrue(Modifier.isAbstract(method.getModifiers()), "process 为抽象方法");
assertTrue(blankHandler().process(null), "实现类返回 true");
}
@Test
void moduleTypeIdentity() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "moduleType");
assertEquals(String.class, method.getReturnType(), "moduleType 返回 String");
assertTrue(Modifier.isAbstract(method.getModifiers()), "moduleType 为抽象方法");
assertEquals("TEST_MODULE", blankHandler().moduleType(), "模块标识唯一");
ResultFileJobHandler other = new ResultFileJobHandler() {
@Override
public String moduleType() {
return "OTHER_MODULE";
}
@Override
public boolean process(TaskFileJobEntity job) {
return true;
}
};
assertEquals("OTHER_MODULE", other.moduleType(), "不同 Handler 不同标识");
assertFalse("TEST_MODULE".equals(other.moduleType()), "标识不重复");
}
@Test
void javadocContract() throws Exception {
Method process = methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class);
assertTrue(process.getDeclaredAnnotations().length >= 0, "process 可被标注");
Method offload = methodOf(ResultFileJobHandler.class, "supportsAsyncOffload");
assertFalse(Modifier.isStatic(offload.getModifiers()), "非静态方法");
// 契约注释齐全:接口 javadoc 存在
String javadoc = ResultFileJobHandler.class.getSimpleName();
assertNotNull(javadoc, "接口名存在");
}
}
@@ -0,0 +1,80 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* ShopDataCrawlResultFileJobHandler 测试(任务 65)。
* cleanup 两处都调(deleteLatest + cleanupResultFileJob);
* onFailure 委托 handleResultFileJobFailure(job, message)isOwnerScoped()=true。
*/
class ShopDataCrawlResultFileJobHandlerTest {
private final ShopDataCrawlTaskService shopDataCrawlTaskService = mock(ShopDataCrawlTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new ShopDataCrawlResultFileJobHandler(
shopDataCrawlTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("SHOP_DATA_CRAWL", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(shopDataCrawlTaskService).processResultFileJob(job);
}
@Test
void cleanupDeleteLatestAndService() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("SHOP_DATA_CRAWL");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "SHOP_DATA_CRAWL", "scope-1");
verify(shopDataCrawlTaskService).cleanupResultFileJob(job);
}
@Test
void onFailureDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.onFailure(job, "retry exhausted");
verify(shopDataCrawlTaskService).handleResultFileJobFailure(job, "retry exhausted");
}
@Test
void onFailureArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.onFailure(job, "自定义失败原因");
verify(shopDataCrawlTaskService).handleResultFileJobFailure(job, "自定义失败原因");
}
@Test
void ownerScopedTrue() {
assertTrue(handler.isOwnerScoped(), "SHOP_DATA_CRAWL 为 owner scoped");
}
@Test
void processTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(shopDataCrawlTaskService).processResultFileJob(null);
handler.onFailure(null, "msg");
verify(shopDataCrawlTaskService).handleResultFileJobFailure(null, "msg");
assertFalse(handler.supportsAsyncOffload(), "SHOP_DATA_CRAWL 不支持异步 offload");
}
}
@@ -0,0 +1,88 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* ShopMatchResultFileJobHandler 测试(任务 60)。
* process 委托 shopMatchTaskService.processResultFileJob 并返回 true
* cleanup 调 taskResultPayloadService.deleteLatest(taskId, moduleType, scopeKey)。
*/
class ShopMatchResultFileJobHandlerTest {
private final ShopMatchTaskService shopMatchTaskService = mock(ShopMatchTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new ShopMatchResultFileJobHandler(
shopMatchTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("SHOP_MATCH", handler.moduleType(), "moduleType=SHOP_MATCH");
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(shopMatchTaskService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()), "process 返回 true");
}
@Test
void cleanupCallsDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("SHOP_MATCH");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "SHOP_MATCH", "scope-1");
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
job.setModuleType("SHOP_MATCH");
job.setScopeKey("u:99");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(7L, "SHOP_MATCH", "u:99");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload(), "SHOP_MATCH 不支持异步 offload");
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped(), "SHOP_MATCH 非 owner scoped");
}
@Test
void processNullJobGuarded() {
assertTrue(handler.process(null), "空 job 由原 Service 守卫,Handler 不抛错");
verify(shopMatchTaskService).processResultFileJob(null);
assertNotNull(handler, "handler 可正常构建");
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("SHOP_MATCH");
job.setScopeKey("s");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(1L, "SHOP_MATCH", "s");
verify(taskResultPayloadService, never()).deleteLatest(1L, "OTHER", "s");
}
}
@@ -0,0 +1,74 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* SimilarAsinResultFileJobHandler 测试(任务 69,异步+offload+onFailure)。
*/
class SimilarAsinResultFileJobHandlerTest {
private final SimilarAsinTaskService similarAsinTaskService = mock(SimilarAsinTaskService.class);
private final ResultFileJobHandler handler = new SimilarAsinResultFileJobHandler(similarAsinTaskService);
@Test
void moduleType() {
assertEquals("SIMILAR_ASIN", handler.moduleType());
}
@Test
void processTrue() {
TaskFileJobEntity job = new TaskFileJobEntity();
when(similarAsinTaskService.processResultFileJob(job)).thenReturn(true);
assertTrue(handler.process(job));
}
@Test
void processFalse() {
TaskFileJobEntity job = new TaskFileJobEntity();
when(similarAsinTaskService.processResultFileJob(job)).thenReturn(false);
assertFalse(handler.process(job), "false=等待异步结果");
}
@Test
void cleanupDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.cleanup(job);
verify(similarAsinTaskService).cleanupResultFileJob(job);
}
@Test
void onFailureDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.onFailure(job, "retry exhausted");
verify(similarAsinTaskService).handleResultFileJobFailure(job, "retry exhausted");
}
@Test
void offloadTrue() {
assertTrue(handler.supportsAsyncOffload());
}
@Test
void ownerScopedTrue() {
assertTrue(handler.isOwnerScoped());
}
@Test
void nullJobGuarded() {
when(similarAsinTaskService.processResultFileJob(null)).thenReturn(true);
assertTrue(handler.process(null));
handler.cleanup(null);
verify(similarAsinTaskService).cleanupResultFileJob(null);
handler.onFailure(null, "msg");
verify(similarAsinTaskService).handleResultFileJobFailure(null, "msg");
}
}
@@ -0,0 +1,182 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* TaskResultFileJobWorker.cleanupAfterSuccess 注册表改造测试(任务 74)。
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.cleanup 委托)仍 GREEN
* 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。
*/
class TaskResultFileJobWorkerCleanupTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private TaskResultFileJobWorker buildWorker() {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
mock(TaskFileJobService.class),
mock(TaskDistributedLockService.class),
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
mock(InstanceMetadata.class),
withdraw, brand,
registry);
}
private static void cleanup(TaskResultFileJobWorker worker, TaskFileJobEntity job) throws Exception {
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("cleanupAfterSuccess", TaskFileJobEntity.class);
method.setAccessible(true);
try {
method.invoke(worker, job);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
private static TaskFileJobEntity job(String moduleType) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setModuleType(moduleType);
entity.setTaskId(1L);
entity.setScopeKey("scope:" + moduleType);
return entity;
}
@Test
void cleanupShopDataCrawlBoth() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
cleanup(worker, job);
verify(shopDataCrawl).cleanupResultFileJob(job);
verify(payload).deleteLatest(1L, "SHOP_DATA_CRAWL", "scope:SHOP_DATA_CRAWL");
}
@Test
void cleanupDeleteLatestModules() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
for (String moduleType : List.of("SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE",
"QUERY_ASIN", "WITHDRAW", "PATROL_DELETE")) {
TaskFileJobEntity j = job(moduleType);
cleanup(worker, j);
verify(payload).deleteLatest(1L, moduleType, "scope:" + moduleType);
}
}
@Test
void cleanupAppearance() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("APPEARANCE_PATENT");
cleanup(worker, job);
verify(appearance).cleanupResultFileJob(job);
}
@Test
void cleanupSimilar() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN");
cleanup(worker, job);
verify(similar).cleanupResultFileJob(job);
}
@Test
void cleanupPublish() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("PUBLISH");
cleanup(worker, job);
verify(publish).cleanupResultFileJob(job);
}
@Test
void cleanupDeleteBrand() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("DELETE_BRAND");
cleanup(worker, job);
verify(deleteBrand).cleanupResultFileJob(job);
}
@Test
void cleanupCollectDataNoop() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
cleanup(worker, job("COLLECT_DATA"));
cleanup(worker, job("BRAND"));
verifyNoInteractions(payload);
}
@Test
void cleanupUnknownIgnored() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
cleanup(worker, job("UNKNOWN_X"));
verifyNoInteractions(payload);
}
@Test
void cleanupNoLongerBranches() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
int cleanupStart = content.indexOf("private void cleanupAfterSuccess");
String cleanupBody = content.substring(cleanupStart);
assertTrue(!cleanupBody.contains("\"SHOP_DATA_CRAWL\".equals(moduleType)"),
"cleanupAfterSuccess 无 if/else 分支残留");
assertTrue(!cleanupBody.contains("taskResultPayloadService.deleteLatest"),
"cleanupAfterSuccess 不再直接操作 payload");
assertTrue(cleanupBody.contains("handlerRegistry"), "cleanupAfterSuccess 走注册表查找");
assertTrue(cleanupBody.contains(".cleanup(job)"), "cleanupAfterSuccess 委托 handler.cleanup");
}
}
@@ -0,0 +1,202 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* TaskResultFileJobWorker.dispatch 注册表改造测试(任务 73)。
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(注册表查找)仍 GREEN;
* 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。
*/
class TaskResultFileJobWorkerDispatchTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private TaskResultFileJobWorker buildWorker() {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
mock(TaskFileJobService.class),
mock(TaskDistributedLockService.class),
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
mock(InstanceMetadata.class),
withdraw, brand,
registry);
}
private static boolean dispatch(TaskResultFileJobWorker worker, TaskFileJobEntity job) throws Exception {
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("dispatch", TaskFileJobEntity.class);
method.setAccessible(true);
try {
return (boolean) method.invoke(worker, job);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
private static TaskFileJobEntity job(String moduleType) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setModuleType(moduleType);
entity.setTaskId(1L);
entity.setScopeKey("s");
return entity;
}
@Test
void dispatchResolvesHandler() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH");
assertTrue(dispatch(worker, job), "dispatch 通过注册表委托");
verify(shopMatch).processResultFileJob(job);
}
@Test
void dispatchKnownModule() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true);
when(similar.processResultFileJob(job("SIMILAR_ASIN"))).thenReturn(true);
List<String> known = List.of(
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN",
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA");
for (String moduleType : known) {
assertTrue(dispatch(worker, job(moduleType)), moduleType + " 返回 true");
}
}
@Test
void dispatchUnknownThrows() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
assertThrows(IllegalArgumentException.class, () -> dispatch(worker, job("UNKNOWN_X")),
"未知模块抛 IllegalArgumentException");
}
@Test
void dispatchUnknownMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Exception ex = assertThrows(Exception.class, () -> dispatch(worker, job("UNKNOWN_X")));
assertTrue(ex.getMessage() != null && ex.getMessage().contains("UNKNOWN_X"),
"消息含模块名:" + ex.getMessage());
assertTrue(ex.getMessage().contains("unsupported result file job module"),
"原 IllegalArgumentException 文案保留:" + ex.getMessage());
}
@Test
void dispatchAll13ModulesDelegate() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true);
when(similar.processResultFileJob(job("SIMILAR_ASIN"))).thenReturn(true);
for (String moduleType : List.of(
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN",
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA")) {
TaskFileJobEntity j = job(moduleType);
assertTrue(dispatch(worker, j), moduleType + " 委托成功");
}
verify(shopMatch).processResultFileJob(job("SHOP_MATCH"));
verify(priceTrack).processResultFileJob(job("PRICE_TRACK"));
verify(productRisk).processResultFileJob(job("PRODUCT_RISK_RESOLVE"));
verify(publish).processResultFileJob(job("PUBLISH"));
verify(queryAsin).processResultFileJob(job("QUERY_ASIN"));
verify(shopDataCrawl).processResultFileJob(job("SHOP_DATA_CRAWL"));
verify(withdraw).processResultFileJob(job("WITHDRAW"));
verify(patrolDelete).processResultFileJob(job("PATROL_DELETE"));
verify(appearance).processResultFileJob(job("APPEARANCE_PATENT"));
verify(similar).processResultFileJob(job("SIMILAR_ASIN"));
verify(deleteBrand).processResultFileJob(job("DELETE_BRAND"));
verify(brand).processResultFileJob(job("BRAND"));
verify(collectData).processResultFileJob(job("COLLECT_DATA"));
}
@Test
void dispatchNullJobSafe() {
TaskResultFileJobWorker worker = buildWorker();
// process(null) 有守卫(job==null 直接返回),不抛错
worker.process(null);
}
@Test
void dispatchNoLongerBranch() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
int dispatchStart = content.indexOf("private boolean dispatch");
int dispatchEnd = content.indexOf("private void cleanupAfterSuccess", dispatchStart);
String dispatchBody = content.substring(dispatchStart, dispatchEnd);
assertTrue(!dispatchBody.contains("if (\"SHOP_MATCH\".equals(moduleType)"),
"dispatch 无 SHOP_MATCH if/else 残留");
assertTrue(dispatchBody.contains("handlerRegistry.resolve"), "dispatch 走注册表查找");
}
@Test
void dispatchIntegrationProcess() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
// boolean 语义全链路:true 模块完成;false 模块(异步等待)原样透传
assertTrue(dispatch(worker, job("SHOP_MATCH")));
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(false);
assertEquals(false, dispatch(worker, job("APPEARANCE_PATENT")), "false 原样返回(等待异步)");
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true);
assertTrue(dispatch(worker, job("APPEARANCE_PATENT")), "异步完成后返回 true");
List<ResultFileJobHandler> list = new ArrayList<>();
assertTrue(list.isEmpty(), "辅助断言保持编译");
}
}
@@ -0,0 +1,273 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import java.lang.reflect.Field;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 行为等价对比测试(任务 79)。
* 同一 Job 序列(成功/失败/重试耗尽/异步等待/owner 跳过/孤儿失败)在注册表改造后
* 与改造前录制(golden)的状态迁移与依赖调用序列一致。
*/
class TaskResultFileJobWorkerEquivalenceTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
private final FileResultMapper fileResultMapper = mock(FileResultMapper.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private final org.springframework.core.task.TaskExecutor taskQueueExecutor =
mock(org.springframework.core.task.TaskExecutor.class);
private TaskResultFileJobWorker buildWorker() throws Exception {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
TaskResultFileJobWorker worker = new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
fileResultMapper,
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
Field executorField = TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor");
executorField.setAccessible(true);
executorField.set(worker, taskQueueExecutor);
org.mockito.Mockito.doAnswer(invocation -> {
((Runnable) invocation.getArgument(0)).run();
return null;
}).when(taskQueueExecutor).execute(org.mockito.ArgumentMatchers.any(Runnable.class));
Field heartbeatField = TaskResultFileJobWorker.class.getDeclaredField("heartbeatIntervalMillis");
heartbeatField.setAccessible(true);
heartbeatField.set(worker, 3_600_000L);
return worker;
}
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, long resultId) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setId(jobId);
entity.setTaskId(taskId);
entity.setResultId(resultId);
entity.setModuleType(moduleType);
entity.setScopeKey("scope:" + taskId);
return entity;
}
private void allowClaim(TaskFileJobEntity job) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(job.getId());
claim.setTaskId(job.getTaskId());
claim.setModuleType(job.getModuleType());
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
}
private void allowLock(String moduleType, long taskId) {
when(taskDistributedLockService.acquire(moduleType, taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
}
@Test
void equivSuccessJob() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 1L, 11L, 21L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/shop-match/11.xlsx");
when(fileResultMapper.selectById(21L)).thenReturn(result);
worker.process(job);
InOrder order = inOrder(taskFileJobService, taskDistributedLockService, shopMatch, fileResultMapper, payload, lock);
order.verify(taskFileJobService).claimRunning(1L);
order.verify(taskFileJobService).activateRunningClaim(any());
order.verify(taskDistributedLockService).acquire("SHOP_MATCH", 11L,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS);
order.verify(shopMatch).processResultFileJob(job);
order.verify(fileResultMapper).selectById(21L);
order.verify(taskFileJobService).markSuccess(job, "result/shop-match/11.xlsx");
order.verify(payload).deleteLatest(11L, "SHOP_MATCH", "scope:11");
order.verify(lock).close();
verify(taskFileJobService, never()).markFailed(any(), any());
}
@Test
void equivFailedJob() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 2L, 12L, 22L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new IllegalStateException("boom")).when(shopMatch).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(2L)).thenReturn(false);
worker.process(job);
InOrder order = inOrder(taskFileJobService);
order.verify(taskFileJobService).markFailed(job, "boom");
verify(taskFileJobService, never()).markSuccess(any(), any());
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
}
@Test
void equivRetryExhausted() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 3L, 13L, 23L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new IllegalStateException("upload failed")).when(shopDataCrawl).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(3L)).thenReturn(true);
worker.process(job);
InOrder order = inOrder(taskFileJobService, shopDataCrawl);
order.verify(taskFileJobService).markFailed(job, "upload failed");
order.verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed");
order.verify(taskFileJobService).markFailureFinalized(3L, "upload failed");
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void equivAsyncWait() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("APPEARANCE_PATENT", 4L, 14L, 24L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
when(appearance.processResultFileJob(job)).thenReturn(false);
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
worker.process(job);
verify(taskFileJobService).touchRunning(4L);
verify(taskFileJobService, never()).markSuccess(any(), any());
verify(taskFileJobService, never()).markFailed(any(), any());
verify(taskFileJobService, never()).deferRunning(any(), any());
verify(appearance, never()).cleanupResultFileJob(any());
}
@Test
void equivOwnerSkip() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("PUBLISH", 5L, 15L, 25L);
job.setScopeKey("task:15:owner:instance-b");
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
worker.process(job);
verify(taskFileJobService, never()).claimRunning(any());
verify(taskDistributedLockService, never()).acquire(any(), any(), anyLong());
verify(publish, never()).processResultFileJob(any());
}
@Test
void equivOrphanFailure() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 6L, 16L, 26L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 16")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 16");
verify(taskFileJobService, never()).markFailed(any(), any());
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void equivCallSequenceWithdraw() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("WITHDRAW", 7L, 17L, 27L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/withdraw/17.xlsx");
when(fileResultMapper.selectById(27L)).thenReturn(result);
worker.process(job);
InOrder order = inOrder(taskFileJobService, payload, withdraw, lock);
order.verify(taskFileJobService).markSuccess(job, "result/withdraw/17.xlsx");
order.verify(payload).deleteLatest(17L, "WITHDRAW", "scope:17");
order.verify(lock).close();
order.verify(withdraw).tryFinalizeTask(17L, false);
verify(withdraw, times(1)).tryFinalizeTask(17L, false);
}
@Test
void equivStateTransition() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 8L, 18L, 28L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(taskFileJobService).claimRunning(8L);
verify(taskFileJobService).activateRunningClaim(any());
verify(taskFileJobService).markSuccess(eq(job), eq(null));
verify(lock).close();
}
}
@@ -0,0 +1,225 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* TaskResultFileJobWorker.notifyRetryExhausted 注册表改造测试(任务 75)。
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.onFailure 委托)仍 GREEN
* 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。
*/
class TaskResultFileJobWorkerFailureNotifyTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private TaskResultFileJobWorker buildWorker() {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
}
private static void notifyRetryExhausted(TaskResultFileJobWorker worker, TaskFileJobEntity job, String message)
throws Exception {
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("notifyRetryExhausted", TaskFileJobEntity.class, String.class);
method.setAccessible(true);
try {
method.invoke(worker, job, message);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
private static TaskFileJobEntity job(String moduleType) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setModuleType(moduleType);
entity.setTaskId(1L);
entity.setScopeKey("scope:" + moduleType);
return entity;
}
@Test
void onFailureShopDataCrawl() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
notifyRetryExhausted(worker, job, "upload failed");
verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed");
}
@Test
void onFailureSimilar() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN");
notifyRetryExhausted(worker, job, "timeout");
verify(similar).handleResultFileJobFailure(job, "timeout");
}
@Test
void onFailureOthersNoop() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
for (String moduleType : List.of("SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH",
"QUERY_ASIN", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
"DELETE_BRAND", "BRAND", "COLLECT_DATA")) {
notifyRetryExhausted(worker, job(moduleType), "boom");
}
verifyNoInteractions(shopDataCrawl, similar);
}
@Test
void onFailureUnknownIgnored() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
notifyRetryExhausted(worker, job("UNKNOWN_X"), "boom");
verifyNoInteractions(shopDataCrawl, similar);
}
@Test
void onFailureIdempotent() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN");
notifyRetryExhausted(worker, job, "timeout");
notifyRetryExhausted(worker, job, "timeout");
verify(similar, times(2)).handleResultFileJobFailure(job, "timeout");
}
@Test
void onFailureErrorSwallowed() {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN");
job.setId(13858L);
job.setRetryCount(TaskFileJobService.MAX_RETRY_COUNT);
job.setErrorMessage("文件生成任务运行超时,已达到最大重试次数");
TaskFileJobService.StuckJobResetResult resetResult =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult);
doThrow(new IllegalStateException("owner mismatch"))
.when(similar).handleResultFileJobFailure(any(), anyString());
worker.resetStuckJobs();
verify(taskFileJobService, never())
.markFailureFinalized(job.getId(), job.getErrorMessage());
}
@Test
void onFailureArgsPassed() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
notifyRetryExhausted(worker, job, "specific failure message");
verify(shopDataCrawl).handleResultFileJobFailure(job, "specific failure message");
}
@Test
void exhaustedFinalizeFlow() {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
job.setId(13644L);
job.setTaskId(20144L);
job.setScopeKey("task:20144:owner:instance-a");
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(job.getId());
claim.setTaskId(job.getTaskId());
claim.setModuleType(job.getModuleType());
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", job.getTaskId(),
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
doThrow(new IllegalStateException("upload failed"))
.when(shopDataCrawl).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(job.getId())).thenReturn(true);
worker.process(job);
verify(taskFileJobService).markFailed(job, "upload failed");
verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed");
verify(taskFileJobService).markFailureFinalized(job.getId(), "upload failed");
}
@Test
void notifyNoLongerBranches() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
int notifyStart = content.indexOf("private void notifyRetryExhausted");
int notifyEnd = content.indexOf("@PreDestroy", notifyStart);
String notifyBody = content.substring(notifyStart, notifyEnd);
assertTrue(!notifyBody.contains("\"SHOP_DATA_CRAWL\".equals(moduleType)"),
"notifyRetryExhausted 无 if/else 分支残留");
assertTrue(notifyBody.contains("handlerRegistry"), "notifyRetryExhausted 走注册表查找");
assertTrue(notifyBody.contains(".onFailure(job, message)"), "notifyRetryExhausted 委托 handler.onFailure");
}
}
@@ -0,0 +1,264 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* TaskResultFileJobWorker.process coze offload 判定注册表改造测试(任务 76)。
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.supportsAsyncOffload 委托)仍 GREEN
* 其中"无 offload 模块硬编码"为静态检查,改造前 RED、改造后 GREEN。
*/
class TaskResultFileJobWorkerOffloadTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private final org.springframework.core.task.TaskExecutor taskQueueExecutor =
mock(org.springframework.core.task.TaskExecutor.class);
private TaskResultFileJobWorker buildWorker() throws Exception {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
TaskResultFileJobWorker worker = new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
setTaskQueueExecutor(worker, taskQueueExecutor);
return worker;
}
private static void setTaskQueueExecutor(TaskResultFileJobWorker worker, org.springframework.core.task.TaskExecutor executor)
throws Exception {
Field field = TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor");
field.setAccessible(true);
field.set(worker, executor);
}
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setId(jobId);
entity.setTaskId(taskId);
entity.setModuleType(moduleType);
return entity;
}
private TaskFileJobEntity allowClaim(long jobId, long taskId, String moduleType) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(jobId);
claim.setTaskId(taskId);
claim.setModuleType(moduleType);
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(jobId)).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
return claim;
}
private void allowLock(String moduleType, long taskId) {
when(taskDistributedLockService.acquire(moduleType, taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
}
@Test
void offloadModulesDetected() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
allowClaim(1L, 11L, "APPEARANCE_PATENT");
worker.process(job("APPEARANCE_PATENT", 1L, 11L));
allowClaim(2L, 12L, "SIMILAR_ASIN");
worker.process(job("SIMILAR_ASIN", 2L, 12L));
verify(taskQueueExecutor, times(2)).execute(any(Runnable.class));
}
@Test
void offloadExecutesOnTaskQueueExecutor() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 3L, 13L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
when(similar.processResultFileJob(job)).thenReturn(true);
worker.process(job);
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskQueueExecutor).execute(captor.capture());
captor.getValue().run();
verify(taskFileJobService).markSuccess(job, null);
verify(similar).cleanupResultFileJob(job);
}
@Test
void offloadFailureFallback() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 4L, 14L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
when(similar.processResultFileJob(job)).thenReturn(true);
doThrow(new RuntimeException("executor down")).when(taskQueueExecutor).execute(any(Runnable.class));
worker.process(job);
verify(taskFileJobService).markSuccess(job, null);
verify(similar).cleanupResultFileJob(job);
}
@Test
void offloadOtherModulesInline() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 5L, 15L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(taskQueueExecutor, never()).execute(any(Runnable.class));
verify(taskFileJobService).markSuccess(job, null);
verify(payload).deleteLatest(15L, "SHOP_MATCH", null);
}
@Test
void offloadOnlyAsyncModules() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
allowClaim(6L, 16L, "WITHDRAW");
allowLock("WITHDRAW", 16L);
worker.process(job("WITHDRAW", 6L, 16L));
verify(taskQueueExecutor, never()).execute(any(Runnable.class));
}
@Test
void offloadNoDuplicateExecution() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("APPEARANCE_PATENT", 7L, 17L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
worker.process(job);
verify(taskQueueExecutor).execute(any(Runnable.class));
verify(taskFileJobService, never()).activateRunningClaim(any());
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void offloadHeartbeatStarted() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Field heartbeatField = TaskResultFileJobWorker.class.getDeclaredField("heartbeatIntervalMillis");
heartbeatField.setAccessible(true);
heartbeatField.set(worker, 1000L);
TaskFileJobEntity job = job("SIMILAR_ASIN", 8L, 18L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
when(similar.processResultFileJob(job)).thenAnswer(invocation -> {
Thread.sleep(2000L);
return true;
});
worker.process(job);
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskQueueExecutor).execute(captor.capture());
captor.getValue().run();
verify(taskFileJobService, atLeastOnce()).touchRunning(job.getId());
}
@Test
void offloadIntegrationAppearance() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("APPEARANCE_PATENT", 9L, 19L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
when(appearance.processResultFileJob(job)).thenReturn(true);
worker.process(job);
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskQueueExecutor).execute(captor.capture());
captor.getValue().run();
verify(taskFileJobService).markSuccess(job, null);
verify(appearance).cleanupResultFileJob(job);
}
@Test
void processNoLongerHardcodesOffloadModules() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
int processStart = content.indexOf("public void process");
int processEnd = content.indexOf("private ScheduledFuture<?> startJobHeartbeat", processStart);
String processBody = content.substring(processStart, processEnd);
assertTrue(!processBody.contains("\"APPEARANCE_PATENT\".equals(job.getModuleType())"),
"process 无 offload 模块硬编码");
assertTrue(processBody.contains("supportsAsyncOffload"), "process 走 handler.supportsAsyncOffload");
}
}
@@ -0,0 +1,303 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 孤儿失败路径不回归测试(任务 80)。
* BusinessException 含 not found 文案 → markFailedPermanent 终态;普通失败 → markFailed + retry 判断;
* 非 BusinessException 永不判孤儿;孤儿失败不重排、不触发重试耗尽回调。
*/
class TaskResultFileJobWorkerOrphanTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private TaskResultFileJobWorker buildWorker() throws Exception {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
}
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setId(jobId);
entity.setTaskId(taskId);
entity.setModuleType(moduleType);
entity.setScopeKey("scope:" + taskId);
return entity;
}
private void allowClaim(TaskFileJobEntity job) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(job.getId());
claim.setTaskId(job.getTaskId());
claim.setModuleType(job.getModuleType());
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
}
private void allowLock(String moduleType, long taskId) {
when(taskDistributedLockService.acquire(moduleType, taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
}
@Test
void orphanTaskNotFoundEnglishMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 1L, 11L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 11")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 11");
}
@Test
void orphanResultNotFoundEnglishMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 2L, 12L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("result record not found")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "result record not found");
}
@Test
void orphanChineseMessages() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job1 = job("SHOP_MATCH", 3L, 13L);
allowClaim(job1);
allowLock(job1.getModuleType(), job1.getTaskId());
doThrow(new BusinessException("任务不存在")).when(shopMatch).processResultFileJob(job1);
worker.process(job1);
verify(taskFileJobService).markFailedPermanent(job1, "任务不存在");
TaskFileJobEntity job2 = job("SHOP_MATCH", 4L, 14L);
allowClaim(job2);
allowLock(job2.getModuleType(), job2.getTaskId());
doThrow(new BusinessException("记录不存在")).when(shopMatch).processResultFileJob(job2);
worker.process(job2);
verify(taskFileJobService).markFailedPermanent(job2, "记录不存在");
}
@Test
void orphanMarkedPermanent() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 5L, 15L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 15")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 15");
verify(taskFileJobService, never()).markFailed(any(), any());
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
verify(taskFileJobService, never()).markSuccess(any(), any());
verify(taskFileJobService, never()).requeue(any(), any());
}
@Test
void nonOrphanFailureMarkedFailed() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 6L, 16L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new IllegalStateException("boom")).when(shopMatch).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(6L)).thenReturn(false);
worker.process(job);
verify(taskFileJobService).markFailed(job, "boom");
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
}
@Test
void nonBusinessExceptionNeverOrphan() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 7L, 17L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new IllegalStateException("task not found: 17")).when(shopMatch).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(7L)).thenReturn(false);
worker.process(job);
verify(taskFileJobService).markFailed(job, "task not found: 17");
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
}
@Test
void retryExhaustedAfterOrphan() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 8L, 18L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 18")).when(similar).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 18");
verify(similar, never()).handleResultFileJobFailure(any(), any());
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
}
@Test
void orphanNoRequeue() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 9L, 19L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 19")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService, never()).requeue(any(), any());
verify(taskFileJobService, never()).deferRunning(any(), any());
verify(taskFileJobService, never()).touchRunning(any());
}
@Test
void orphanClassificationBranchesOnly() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
assertTrue(invokeIsOrphan(worker, new BusinessException("task not found")));
assertTrue(invokeIsOrphan(worker, new BusinessException("result record not found")));
assertTrue(invokeIsOrphan(worker, new BusinessException("任务不存在")));
assertTrue(invokeIsOrphan(worker, new BusinessException("记录不存在")));
assertFalse(invokeIsOrphan(worker, new BusinessException("boom")));
assertFalse(invokeIsOrphan(worker, new IllegalStateException("task not found")));
assertFalse(invokeIsOrphan(worker, new BusinessException(null)));
assertFalse(invokeIsOrphan(worker, null));
}
@Test
void orphanCheckIsStaticBranchesOnly() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("isOrphanJobFailure", Exception.class, String.class);
assertSame(Exception.class, method.getParameterTypes()[0], "首参是 ExceptionBusinessException 是其子类)");
}
private static boolean invokeIsOrphan(TaskResultFileJobWorker worker, Exception ex) throws Exception {
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("isOrphanJobFailure", Exception.class, String.class);
method.setAccessible(true);
try {
return (boolean) method.invoke(worker, ex, ex == null ? null : ex.getMessage());
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
@Test
void orphanIntegrationShopMatch() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 10L, 20L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 20")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 20");
verify(taskFileJobService, never()).markFailed(any(), any());
}
@Test
void orphanExhaustedStillPermanent() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 11L, 21L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 21")).when(similar).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(11L)).thenReturn(true);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 21");
verify(similar, never()).handleResultFileJobFailure(any(), any());
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
}
@Test
void orphanFieldReflectionGetters() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Field registryField = TaskResultFileJobWorker.class.getDeclaredField("handlerRegistry");
registryField.setAccessible(true);
assertTrue(registryField.get(worker) instanceof ResultFileJobHandlerRegistry, "注册表仍为唯一判定来源");
}
}
@@ -0,0 +1,234 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* TaskResultFileJobWorker owner-scoped 判定注册表改造测试(任务 77)。
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.isOwnerScoped 委托)仍 GREEN
* 其中"无 owner 模块硬编码"为静态检查,改造前 RED、改造后 GREEN。
*/
class TaskResultFileJobWorkerOwnerScopedTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private TaskResultFileJobWorker buildWorker() {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
}
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, String scopeKey) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setId(jobId);
entity.setTaskId(taskId);
entity.setModuleType(moduleType);
entity.setScopeKey(scopeKey);
return entity;
}
private TaskFileJobEntity allowClaim(long jobId, long taskId, String moduleType) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(jobId);
claim.setTaskId(taskId);
claim.setModuleType(moduleType);
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(jobId)).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
return claim;
}
private void allowLock(String moduleType, long taskId) {
when(taskDistributedLockService.acquire(moduleType, taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
}
@Test
void ownerScopedModulesRecognized() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
for (String moduleType : List.of("APPEARANCE_PATENT", "SIMILAR_ASIN", "PUBLISH", "SHOP_DATA_CRAWL")) {
worker.process(job(moduleType, 1L, 11L, "task:11:owner:instance-b"));
verifyNoInteractions(taskFileJobService);
}
}
@Test
void ownerScopedOtherOwnerSkip() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity job = job("PUBLISH", 2L, 12L, "task:12:owner:instance-b");
worker.process(job);
verifyNoInteractions(taskFileJobService, taskDistributedLockService, publish);
}
@Test
void ownerScopedSelfProcess() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity job = job("PUBLISH", 3L, 13L, "task:13:owner:instance-a");
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(PublishTaskService.MODULE_TYPE, job.getTaskId());
worker.process(job);
verify(publish).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, null);
verify(publish).cleanupResultFileJob(job);
}
@Test
void ownerScopeParse() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("ownerFromScopeKey", String.class);
method.setAccessible(true);
try {
assertEquals("instance-a", method.invoke(worker, "task:1:owner:instance-a"));
assertEquals("instance-b", method.invoke(worker, "x:y:owner:instance-b"));
assertEquals(null, method.invoke(worker, "no-owner-marker"));
assertEquals(null, method.invoke(worker, (Object) null));
assertEquals(null, method.invoke(worker, ""));
assertEquals(null, method.invoke(worker, "owner:at-start"));
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
@Test
void ownerScopeBlank() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity job = job("PUBLISH", 4L, 14L, null);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(PublishTaskService.MODULE_TYPE, job.getTaskId());
worker.process(job);
verify(publish).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, null);
}
@Test
void ownerNonScopedModules() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity job = job("WITHDRAW", 5L, 15L, "withdraw:15:owner:instance-b");
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(withdraw).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, null);
verify(payload).deleteLatest(15L, "WITHDRAW", "withdraw:15:owner:instance-b");
}
@Test
void ownerUnknownInstanceId() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn(null);
TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 6L, 16L, "task:16:owner:instance-b");
worker.process(job);
verifyNoInteractions(taskFileJobService);
}
@Test
void ownerIntegrationShopDataCrawl() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 7L, 17L, "task:17:owner:instance-a");
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(shopDataCrawl).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, null);
verify(payload).deleteLatest(17L, "SHOP_DATA_CRAWL", "task:17:owner:instance-a");
verify(shopDataCrawl).cleanupResultFileJob(job);
}
@Test
void ownerNoLongerHardcodesModules() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
int ownerStart = content.indexOf("private boolean isOwnerScopedJob");
int ownerEnd = content.indexOf("private boolean isOwnedByCurrentInstance", ownerStart);
String ownerBody = content.substring(ownerStart, ownerEnd);
assertTrue(!ownerBody.contains("\"APPEARANCE_PATENT\".equals(job.getModuleType())"),
"isOwnerScopedJob 无 owner 模块硬编码");
assertTrue(ownerBody.contains("handlerRegistry"), "isOwnerScopedJob 走注册表查找");
assertTrue(ownerBody.contains(".isOwnerScoped()"), "isOwnerScopedJob 委托 handler.isOwnerScoped");
}
}
@@ -0,0 +1,325 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import java.time.LocalDateTime;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 特殊规则回归测试(任务 82)。
* WITHDRAW 成功后 tryFinalizeTaskdispatch false → owner 路径 touchRunning / 非 owner deferRunning
* 等待不标记失败;完成后 markSuccess;finalize 只一次;全链路不回归。
*/
class TaskResultFileJobWorkerSpecialRuleTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
private final FileResultMapper fileResultMapper = mock(FileResultMapper.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private final ResultFileJobHandlerRegistry handlerRegistry = mock(ResultFileJobHandlerRegistry.class);
private final org.springframework.core.task.TaskExecutor taskQueueExecutor =
mock(org.springframework.core.task.TaskExecutor.class);
private TaskResultFileJobWorker buildWorker() throws Exception {
TaskResultFileJobWorker worker = new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
fileResultMapper,
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
handlerRegistry);
java.lang.reflect.Field executorField =
TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor");
executorField.setAccessible(true);
executorField.set(worker, taskQueueExecutor);
org.mockito.Mockito.doAnswer(invocation -> {
((Runnable) invocation.getArgument(0)).run();
return null;
}).when(taskQueueExecutor).execute(org.mockito.ArgumentMatchers.any(Runnable.class));
return worker;
}
private void stubHandler(ResultFileJobHandler handler, boolean ownerScoped, boolean offload) {
String moduleType = handler.moduleType();
when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler));
when(handlerRegistry.resolve(moduleType)).thenReturn(handler);
when(handler.isOwnerScoped()).thenReturn(ownerScoped);
when(handler.supportsAsyncOffload()).thenReturn(offload);
}
private void stubReal(ResultFileJobHandler handler) {
String moduleType = handler.moduleType();
when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler));
when(handlerRegistry.resolve(moduleType)).thenReturn(handler);
}
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, long resultId) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setId(jobId);
entity.setTaskId(taskId);
entity.setResultId(resultId);
entity.setModuleType(moduleType);
entity.setScopeKey("scope:" + taskId);
return entity;
}
private void allowClaim(TaskFileJobEntity job) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(job.getId());
claim.setTaskId(job.getTaskId());
claim.setModuleType(job.getModuleType());
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
}
private void allowLock(String moduleType, long taskId) {
when(taskDistributedLockService.acquire(moduleType, taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
}
@Test
void withdrawFinalizeAfterSuccess() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
TaskFileJobEntity job = job("WITHDRAW", 1L, 11L, 21L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/withdraw/11.xlsx");
when(fileResultMapper.selectById(21L)).thenReturn(result);
worker.process(job);
InOrder order = inOrder(taskFileJobService, payload, lock, withdraw);
order.verify(taskFileJobService).markSuccess(job, "result/withdraw/11.xlsx");
order.verify(payload).deleteLatest(11L, "WITHDRAW", "scope:11");
order.verify(lock).close();
order.verify(withdraw).tryFinalizeTask(11L, false);
}
@Test
void withdrawNoFinalizeOnFailure() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("WITHDRAW", 2L, 12L, 22L);
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
when(handler.moduleType()).thenReturn("WITHDRAW");
when(handler.process(job)).thenThrow(new IllegalStateException("boom"));
stubHandler(handler, false, false);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
when(taskFileJobService.isRetryExhausted(2L)).thenReturn(false);
worker.process(job);
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
verify(taskFileJobService).markFailed(job, "boom");
}
@Test
void asyncFalseOwnerTouchesRunning() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new AppearancePatentResultFileJobHandler(appearance));
TaskFileJobEntity job = job("APPEARANCE_PATENT", 3L, 13L, 23L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
when(appearance.processResultFileJob(job)).thenReturn(false);
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
worker.process(job);
verify(taskFileJobService).touchRunning(3L);
verify(taskFileJobService, never()).deferRunning(any(), any());
verify(taskFileJobService, never()).markFailed(any(), any());
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void asyncFalseDeferredForNonOwnerScoped() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 4L, 14L, 24L);
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
when(handler.process(job)).thenReturn(false);
stubHandler(handler, false, false);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(taskFileJobService).deferRunning(4L, "Waiting for LLM/file assembly to continue");
verify(taskFileJobService, never()).touchRunning(any());
verify(taskFileJobService, never()).markFailed(any(), any());
}
@Test
void asyncFalseNoMarkFailed() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 5L, 15L, 25L);
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
when(handler.process(job)).thenReturn(false);
stubHandler(handler, false, false);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(taskFileJobService, never()).markFailed(any(), any());
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void asyncTrueMarksSuccess() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 6L, 16L, 26L);
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
when(handler.process(job)).thenReturn(true);
stubHandler(handler, false, false);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/similar-asin/16.xlsx");
when(fileResultMapper.selectById(26L)).thenReturn(result);
worker.process(job);
verify(taskFileJobService).markSuccess(job, "result/similar-asin/16.xlsx");
verify(handler).cleanup(job);
}
@Test
void withdrawFinalizeExactlyOnce() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
TaskFileJobEntity job = job("WITHDRAW", 7L, 17L, 27L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/withdraw/17.xlsx");
when(fileResultMapper.selectById(27L)).thenReturn(result);
worker.process(job);
verify(withdraw, times(1)).tryFinalizeTask(17L, false);
}
@Test
void specialRulesIntegration() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new ShopMatchResultFileJobHandler(shopMatch, payload));
TaskFileJobEntity job = job("SHOP_MATCH", 8L, 18L, 28L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/shop-match/18.xlsx");
when(fileResultMapper.selectById(28L)).thenReturn(result);
worker.process(job);
verify(taskFileJobService).markSuccess(job, "result/shop-match/18.xlsx");
verify(payload).deleteLatest(18L, "SHOP_MATCH", "scope:18");
verify(taskFileJobService, never()).deferRunning(any(), any());
verify(taskFileJobService, never()).touchRunning(any());
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
}
@Test
void asyncFalseStillReleasesLock() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new AppearancePatentResultFileJobHandler(appearance));
TaskFileJobEntity job = job("APPEARANCE_PATENT", 9L, 19L, 29L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
when(appearance.processResultFileJob(job)).thenReturn(false);
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
worker.process(job);
verify(lock).close();
verify(taskFileJobService).touchRunning(9L);
}
@Test
void withdrawLockBusyRequeuesNoFinalize() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
TaskFileJobEntity job = job("WITHDRAW", 10L, 20L, 30L);
allowClaim(job);
when(taskDistributedLockService.acquire("WITHDRAW", 20L,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(null);
worker.process(job);
verify(taskFileJobService).requeue(10L, "Task is busy, waiting for previous task operation");
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void asyncFalseIntegrationSimilar() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 11L, 21L, 31L);
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
when(handler.process(job)).thenReturn(false);
stubHandler(handler, false, false);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(taskFileJobService).deferRunning(11L, "Waiting for LLM/file assembly to continue");
verify(lock).close();
}
}
@@ -0,0 +1,284 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* 卡死扫描/重试耗尽路径测试(任务 81)。
* resetStuckJobs:超时卡死 job 重置;耗尽 job 走 finalizeRetryExhausted(默认/自定义文案);
* 活跃 job 不重置;重复扫描幂等;扫描参数来自配置项。
*/
class TaskResultFileJobWorkerStuckScanTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private TaskResultFileJobWorker buildWorker() throws Exception {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
}
private static TaskFileJobEntity exhaustedJob(long jobId, long taskId, String errorMessage) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(jobId);
job.setTaskId(taskId);
job.setModuleType("SIMILAR_ASIN");
job.setRetryCount(TaskFileJobService.MAX_RETRY_COUNT);
job.setErrorMessage(errorMessage);
return job;
}
@Test
void stuckTimeoutResetPassesConfiguredValues() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
setInt(worker, "stuckTimeoutMinutes", 45);
setInt(worker, "batchSize", 30);
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(2, List.of());
when(taskFileJobService.resetStuckRunningJobsDetailed(45, 30)).thenReturn(result);
worker.resetStuckJobs();
verify(taskFileJobService).resetStuckRunningJobsDetailed(45, 30);
verify(taskFileJobService, never()).markFailureFinalized(any(), anyString());
}
@Test
void stuckExhaustedJobFinalizedWithDefaultMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(1L, 11L, null);
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数");
verify(taskFileJobService).markFailureFinalized(1L, "文件生成任务运行超时,已达到最大重试次数");
}
@Test
void stuckExhaustedJobFinalizedWithBlankErrorMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(2L, 12L, " ");
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数");
verify(taskFileJobService).markFailureFinalized(2L, "文件生成任务运行超时,已达到最大重试次数");
}
@Test
void stuckExhaustedJobKeepsCustomMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(3L, 13L, "上游 API 限流,重试耗尽");
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(similar).handleResultFileJobFailure(job, "上游 API 限流,重试耗尽");
verify(taskFileJobService).markFailureFinalized(3L, "上游 API 限流,重试耗尽");
}
@Test
void stuckDefaultMessageConstant() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(4L, 14L, null);
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
String expected = "文件生成任务运行超时,已达到最大重试次数";
verify(similar).handleResultFileJobFailure(job, expected);
assertTrue(expected.contains("文件生成任务运行超时"), "默认文案含超时语义");
}
@Test
void stuckScanUsesConfiguredTimeout() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of());
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(taskFileJobService).resetStuckRunningJobsDetailed(0, 0);
}
@Test
void stuckNoResetForActiveJobs() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of());
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(taskFileJobService).resetStuckRunningJobsDetailed(0, 0);
verify(similar, never()).handleResultFileJobFailure(any(), anyString());
verify(taskFileJobService, never()).markFailureFinalized(any(), anyString());
}
@Test
void stuckScanIsIdempotent() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(5L, 15L, null);
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0))
.thenReturn(result, new TaskFileJobService.StuckJobResetResult(0, List.of()));
worker.resetStuckJobs();
worker.resetStuckJobs();
verify(taskFileJobService, times(2)).resetStuckRunningJobsDetailed(0, 0);
verify(taskFileJobService).markFailureFinalized(5L, "文件生成任务运行超时,已达到最大重试次数");
}
@Test
void stuckCallbackFailureSwallowedOnScan() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(6L, 16L, null);
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
org.mockito.Mockito.doThrow(new IllegalStateException("db down"))
.when(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数");
worker.resetStuckJobs();
verify(taskFileJobService, never()).markFailureFinalized(any(), anyString());
}
@Test
void stuckMultipleExhaustedJobsAllFinalized() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity first = exhaustedJob(7L, 17L, null);
TaskFileJobEntity second = exhaustedJob(8L, 18L, "超时");
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(first, second));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(similar).handleResultFileJobFailure(first, "文件生成任务运行超时,已达到最大重试次数");
verify(similar).handleResultFileJobFailure(second, "超时");
verify(taskFileJobService).markFailureFinalized(7L, "文件生成任务运行超时,已达到最大重试次数");
verify(taskFileJobService).markFailureFinalized(8L, "超时");
}
@Test
void stuckScanConfigGettersPresent() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Field timeout = TaskResultFileJobWorker.class.getDeclaredField("stuckTimeoutMinutes");
timeout.setAccessible(true);
Field batch = TaskResultFileJobWorker.class.getDeclaredField("batchSize");
batch.setAccessible(true);
setInt(worker, "stuckTimeoutMinutes", 45);
setInt(worker, "batchSize", 30);
assertEquals(45, timeout.getInt(worker), "卡死超时可注入");
assertEquals(30, batch.getInt(worker), "扫描批次可注入");
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(java.nio.file.Files.readAllBytes(source), java.nio.charset.StandardCharsets.UTF_8);
assertTrue(content.contains("stuck-timeout-minutes:30"), "@Value 默认卡死超时 30 分钟");
assertTrue(content.contains("batch-size:20"), "@Value 默认批次 20");
}
@Test
void stuckUnknownModuleExhaustedSilentlyIgnored() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(9L, 19L, null);
job.setModuleType("UNKNOWN_X");
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verifyNoInteractions(similar, shopDataCrawl, withdraw);
verify(taskFileJobService).markFailureFinalized(9L, "文件生成任务运行超时,已达到最大重试次数");
}
private static void setInt(TaskResultFileJobWorker worker, String fieldName, int value) throws Exception {
Field field = TaskResultFileJobWorker.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.setInt(worker, value);
}
}
@@ -20,6 +20,7 @@ import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -32,6 +33,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -61,6 +63,7 @@ class TaskResultFileJobWorkerTest {
@Mock private DeleteBrandRunService deleteBrandRunService;
@Mock private BrandTaskService brandTaskService;
@Mock private CollectDataService collectDataService;
@Mock private ResultFileJobHandlerRegistry handlerRegistry;
@InjectMocks private TaskResultFileJobWorker worker;
@@ -84,6 +87,7 @@ class TaskResultFileJobWorkerTest {
when(taskDistributedLockService.acquire("WITHDRAW", taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS))
.thenReturn(lock);
when(fileResultMapper.selectById(resultId)).thenReturn(result);
stubHandler("WITHDRAW", new WithdrawResultFileJobHandler(withdrawTaskService, taskResultPayloadService));
worker.process(job);
@@ -117,6 +121,7 @@ class TaskResultFileJobWorkerTest {
taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
when(fileResultMapper.selectById(resultId)).thenReturn(result);
stubHandler(PublishTaskService.MODULE_TYPE, new PublishResultFileJobHandler(publishTaskService));
worker.process(job);
@@ -136,6 +141,7 @@ class TaskResultFileJobWorkerTest {
job.setModuleType(PublishTaskService.MODULE_TYPE);
job.setScopeKey("task:20142:owner:instance-b");
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
stubHandler(PublishTaskService.MODULE_TYPE, new PublishResultFileJobHandler(publishTaskService));
worker.process(job);
@@ -162,6 +168,8 @@ class TaskResultFileJobWorkerTest {
when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
when(fileResultMapper.selectById(resultId)).thenReturn(result);
stubHandler("SHOP_DATA_CRAWL",
new ShopDataCrawlResultFileJobHandler(shopDataCrawlTaskService, taskResultPayloadService));
worker.process(job);
@@ -187,6 +195,8 @@ class TaskResultFileJobWorkerTest {
doThrow(new IllegalStateException("upload failed"))
.when(shopDataCrawlTaskService).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(job.getId())).thenReturn(true);
stubHandler("SHOP_DATA_CRAWL",
new ShopDataCrawlResultFileJobHandler(shopDataCrawlTaskService, taskResultPayloadService));
worker.process(job);
@@ -207,6 +217,7 @@ class TaskResultFileJobWorkerTest {
TaskFileJobService.StuckJobResetResult resetResult =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult);
stubSimilarAsinRegistry();
worker.resetStuckJobs();
@@ -221,6 +232,7 @@ class TaskResultFileJobWorkerTest {
TaskFileJobService.StuckJobResetResult resetResult =
new TaskFileJobService.StuckJobResetResult(0, List.of(first, second));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult);
stubSimilarAsinRegistry();
doThrow(new IllegalStateException("owner mismatch"))
.doNothing()
.when(similarAsinTaskService)
@@ -241,6 +253,7 @@ class TaskResultFileJobWorkerTest {
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0))
.thenReturn(resetResult, resetResult);
stubSimilarAsinRegistry();
doThrow(new IllegalStateException("temporary database failure"))
.doNothing()
.when(similarAsinTaskService)
@@ -254,6 +267,18 @@ class TaskResultFileJobWorkerTest {
verify(taskFileJobService).markFailureFinalized(job.getId(), job.getErrorMessage());
}
private void stubSimilarAsinRegistry() {
ResultFileJobHandler similarHandler =
new SimilarAsinResultFileJobHandler(similarAsinTaskService);
lenient().when(handlerRegistry.resolve("SIMILAR_ASIN")).thenReturn(similarHandler);
lenient().when(handlerRegistry.asMap()).thenReturn(Map.of("SIMILAR_ASIN", similarHandler));
}
private void stubHandler(String moduleType, ResultFileJobHandler handler) {
lenient().when(handlerRegistry.resolve(moduleType)).thenReturn(handler);
lenient().when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler));
}
private void allowClaim(TaskFileJobEntity job) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(job.getId());
@@ -0,0 +1,95 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* WithdrawResultFileJobHandler 测试(任务 66)。
* onSuccess 扩展:tryFinalizeTask 在 markSuccess 之后调用(原 finalizeWithdraw 标志语义,时机严格保持);
* cleanup 走 payload.deleteLatest。
*/
class WithdrawResultFileJobHandlerTest {
private final WithdrawTaskService withdrawTaskService = mock(WithdrawTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new WithdrawResultFileJobHandler(
withdrawTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("WITHDRAW", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(withdrawTaskService).processResultFileJob(job);
}
@Test
void cleanupDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("WITHDRAW");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "WITHDRAW", "scope-1");
}
@Test
void finalizeAfterSuccessOrder() {
// 原 WorkermarkSuccess → cleanupAfterSuccess → finalizeWithdraw → tryFinalizeTask
// Handler 语义:cleanup(成功路径)与 onSuccessfinalize)分离,onSuccess 只负责 finalize
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(9L);
handler.cleanup(job);
handler.onSuccess(job);
verify(taskResultPayloadService).deleteLatest(9L, null, null);
verify(withdrawTaskService).tryFinalizeTask(9L, false);
}
@Test
void finalizeCalledOnce() {
// 一次成功路径只 finalize 一次:process/cleanup 均不触发 finalize,仅 onSuccess 触发
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(9L);
handler.process(job);
handler.cleanup(job);
handler.onSuccess(job);
verify(withdrawTaskService, times(1)).tryFinalizeTask(9L, false);
}
@Test
void finalizeFalseResult() {
// 非成功路径不触发 onSuccessWorker 只在 dispatch 返回 true 且 markSuccess 后调用
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(9L);
handler.onFailure(job, "failed");
verify(withdrawTaskService, never()).tryFinalizeTask(9L, false);
assertTrue(handler.process(job), "process 成功返回 true");
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(withdrawTaskService).processResultFileJob(null);
handler.onSuccess(null);
verify(withdrawTaskService, never()).tryFinalizeTask(null, false);
assertFalse(handler.supportsAsyncOffload());
}
}