task-98: 移除 similar-asin/appearance-patent 模块 Coze,状态机与共享组件改名 LLM
Build Backend JAR / build (push) Has been cancelled
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:
+79
@@ -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 的 boolean(false=等待异步结果);cleanup→cleanupResultFileJob;
|
||||
* supportsAsyncOffload()=true;isOwnerScoped()=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);
|
||||
}
|
||||
}
|
||||
+101
@@ -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);
|
||||
}
|
||||
}
|
||||
+76
@@ -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");
|
||||
}
|
||||
}
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+79
@@ -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 测试(任务 63,ownerScoped)。
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+189
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -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),
|
||||
"缺注册时重复校验稳定抛错");
|
||||
}
|
||||
}
|
||||
+112
@@ -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 键大小写敏感");
|
||||
}
|
||||
}
|
||||
+127
@@ -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,
|
||||
"接口方法数量为 7(moduleType/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, "接口名存在");
|
||||
}
|
||||
}
|
||||
+80
@@ -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");
|
||||
}
|
||||
}
|
||||
+88
@@ -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");
|
||||
}
|
||||
}
|
||||
+74
@@ -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");
|
||||
}
|
||||
}
|
||||
+182
@@ -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");
|
||||
}
|
||||
}
|
||||
+202
@@ -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(), "辅助断言保持编译");
|
||||
}
|
||||
}
|
||||
+273
@@ -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();
|
||||
}
|
||||
}
|
||||
+225
@@ -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");
|
||||
}
|
||||
}
|
||||
+264
@@ -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");
|
||||
}
|
||||
}
|
||||
+303
@@ -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], "首参是 Exception(BusinessException 是其子类)");
|
||||
}
|
||||
|
||||
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, "注册表仍为唯一判定来源");
|
||||
}
|
||||
}
|
||||
+234
@@ -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");
|
||||
}
|
||||
}
|
||||
+325
@@ -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 成功后 tryFinalizeTask;dispatch 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();
|
||||
}
|
||||
}
|
||||
+284
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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());
|
||||
|
||||
+95
@@ -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() {
|
||||
// 原 Worker:markSuccess → cleanupAfterSuccess → finalizeWithdraw → tryFinalizeTask
|
||||
// Handler 语义:cleanup(成功路径)与 onSuccess(finalize)分离,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() {
|
||||
// 非成功路径不触发 onSuccess:Worker 只在 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user