task-5: 预览行数量改为配置驱动并做边界校验
解析响应预览上限从硬编码常量改为 SimilarAsinProperties.parseResponsePreviewLimit, 0/负值回退默认 100,超过 1000 clamp 到 1000 防止误配导致响应体无界膨胀。 新增 8 个测试覆盖默认路径、自定义值、幂等、空文件、单行、超限 clamp、非法配置回退、依赖失败恢复。
This commit is contained in:
+255
@@ -0,0 +1,255 @@
|
||||
package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.InjectMocks;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 5:预览行数量增加配置边界、空文件和超限输入校验。
|
||||
* 预览上限从硬编码常量改为配置驱动,并对配置值做 clamp 边界保护。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SimilarAsinTaskServicePreviewConfigTest {
|
||||
|
||||
private static final AtomicLong NEXT_ID = new AtomicLong(20000);
|
||||
|
||||
@Mock private LocalFileStorageService localFileStorageService;
|
||||
@Mock private FileTaskMapper fileTaskMapper;
|
||||
@Mock private FileResultMapper fileResultMapper;
|
||||
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||
|
||||
@InjectMocks private SimilarAsinTaskService service;
|
||||
|
||||
@BeforeAll
|
||||
static void initializeMybatisMetadata() {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||
.thenReturn("rustfs:task-parsed/similar-asin/20000/payload.json");
|
||||
lenient().doAnswer(invocation -> {
|
||||
FileTaskEntity task = invocation.getArgument(0);
|
||||
task.setId(NEXT_ID.incrementAndGet());
|
||||
return 1;
|
||||
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
|
||||
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
|
||||
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
|
||||
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void shutdown() {
|
||||
service.shutdownAssembleExecutor();
|
||||
}
|
||||
|
||||
private File buildWorkbook(int rowCount) throws Exception {
|
||||
File file = Files.createTempFile("similar-asin-preview-config-", ".xlsx").toFile();
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
|
||||
var sheet = workbook.createSheet("Sheet1");
|
||||
Row header = sheet.createRow(0);
|
||||
header.createCell(0).setCellValue("id");
|
||||
header.createCell(1).setCellValue("asin");
|
||||
header.createCell(2).setCellValue("国家");
|
||||
for (int i = 1; i <= rowCount; i++) {
|
||||
Row row = sheet.createRow(i);
|
||||
row.createCell(0).setCellValue(String.valueOf(i));
|
||||
row.createCell(1).setCellValue(String.format("B0CFG%05d", i));
|
||||
row.createCell(2).setCellValue("英国");
|
||||
}
|
||||
workbook.write(fos);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private SimilarAsinParseRequest request(String fileKey) {
|
||||
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
|
||||
request.setUserId(7L);
|
||||
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
|
||||
sourceFile.setFileKey(fileKey);
|
||||
sourceFile.setOriginalFilename("config.xlsx");
|
||||
request.setFiles(List.of(sourceFile));
|
||||
request.setApiKey("sk-123");
|
||||
request.setImgSwitch(Boolean.FALSE);
|
||||
request.setCategorySwitch(Boolean.FALSE);
|
||||
return request;
|
||||
}
|
||||
|
||||
private SimilarAsinParseVo parse(File workbook, String fileKey) {
|
||||
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
|
||||
return service.parseAndCreateTask(request(fileKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_005_preview_row_count_normal_default_path() throws Exception {
|
||||
// 默认配置 100:150 行文件返回 100 预览行
|
||||
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||
File workbook = buildWorkbook(150);
|
||||
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-default.xlsx");
|
||||
assertEquals(100, vo.getItems().size());
|
||||
assertEquals(150, vo.getAcceptedRows());
|
||||
// 预览行从第 1 行开始
|
||||
assertEquals("B0CFG00001", vo.getItems().get(0).getAsin());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_005_preview_row_count_normal_multiple_items() throws Exception {
|
||||
// 配置 50:500 行文件返回 50 预览行
|
||||
when(properties.getParseResponsePreviewLimit()).thenReturn(50);
|
||||
File workbook = buildWorkbook(500);
|
||||
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-50.xlsx");
|
||||
assertEquals(50, vo.getItems().size());
|
||||
assertEquals(500, vo.getAcceptedRows());
|
||||
// groups 同样按配置裁剪
|
||||
assertTrue(vo.getGroups().size() <= 50);
|
||||
// 配置 200:300 行文件返回 200 预览行
|
||||
when(properties.getParseResponsePreviewLimit()).thenReturn(200);
|
||||
SimilarAsinParseVo vo2 = parse(buildWorkbook(300), "uploads/20260829/cfg-200.xlsx");
|
||||
assertEquals(200, vo2.getItems().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_005_preview_row_count_normal_repeated_operation_is_idempotent() throws Exception {
|
||||
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||
File workbook = buildWorkbook(250);
|
||||
SimilarAsinParseVo first = parse(workbook, "uploads/20260829/cfg-idem.xlsx");
|
||||
SimilarAsinParseVo second = parse(workbook, "uploads/20260829/cfg-idem.xlsx");
|
||||
assertEquals(first.getItems().size(), second.getItems().size());
|
||||
for (int i = 0; i < first.getItems().size(); i++) {
|
||||
assertEquals(first.getItems().get(i).getAsin(), second.getItems().get(i).getAsin());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_005_preview_row_count_boundary_empty_input() throws Exception {
|
||||
// 空文件(无有效数据行):抛业务异常
|
||||
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||
File workbook = buildWorkbook(0);
|
||||
when(localFileStorageService.findLocalSourceFile("uploads/20260829/cfg-empty.xlsx")).thenReturn(workbook);
|
||||
assertThrows(BusinessException.class, () -> parse(workbook, "uploads/20260829/cfg-empty.xlsx"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_005_preview_row_count_boundary_single_item() throws Exception {
|
||||
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||
File workbook = buildWorkbook(1);
|
||||
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-single.xlsx");
|
||||
assertEquals(1, vo.getItems().size());
|
||||
assertEquals("B0CFG00001", vo.getItems().get(0).getAsin());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_005_preview_row_count_boundary_limit_and_overflow() throws Exception {
|
||||
// 配置值超过最大上限(1000):clamp 到上限,不发生无界内存增长
|
||||
when(properties.getParseResponsePreviewLimit()).thenReturn(50000);
|
||||
File workbook = buildWorkbook(2000);
|
||||
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-over.xlsx");
|
||||
assertEquals(1000, vo.getItems().size(), "超限配置必须 clamp 到最大允许值");
|
||||
assertEquals(2000, vo.getAcceptedRows());
|
||||
// 配置值恰好等于上限:返回 1000 预览行
|
||||
when(properties.getParseResponsePreviewLimit()).thenReturn(1000);
|
||||
SimilarAsinParseVo vo2 = parse(buildWorkbook(1000), "uploads/20260829/cfg-exact.xlsx");
|
||||
assertEquals(1000, vo2.getItems().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_005_preview_row_count_invalid_input_rejected() throws Exception {
|
||||
// 配置为 0/负数:回退到默认值 100,不抛异常不崩溃
|
||||
when(properties.getParseResponsePreviewLimit()).thenReturn(0, -5);
|
||||
File workbook = buildWorkbook(300);
|
||||
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-zero.xlsx");
|
||||
assertEquals(100, vo.getItems().size());
|
||||
SimilarAsinParseVo vo2 = parse(buildWorkbook(300), "uploads/20260829/cfg-neg.xlsx");
|
||||
assertEquals(100, vo2.getItems().size());
|
||||
// 配置缺失(fresh mock 未 stub,int 默认 0):同样回退默认
|
||||
SimilarAsinProperties missing = org.mockito.Mockito.mock(SimilarAsinProperties.class);
|
||||
lenient().when(missing.isBoundedResultAssemblyEnabled()).thenReturn(true);
|
||||
lenient().when(missing.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
|
||||
lenient().when(missing.getResultFileTimeoutMinutes()).thenReturn(90);
|
||||
ReflectionTestUtils.setField(service, "properties", missing);
|
||||
SimilarAsinParseVo vo3 = parse(buildWorkbook(300), "uploads/20260829/cfg-missing.xlsx");
|
||||
assertEquals(100, vo3.getItems().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_005_preview_row_count_dependency_failure_releases_resources() throws Exception {
|
||||
// 存储失败抛异常;恢复后解析正常
|
||||
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
|
||||
File workbook = buildWorkbook(120);
|
||||
when(localFileStorageService.findLocalSourceFile("uploads/20260829/cfg-fail.xlsx")).thenReturn(workbook);
|
||||
when(transientPayloadStorageService.storeParsedPayloadFast(
|
||||
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
|
||||
.thenThrow(new IllegalStateException("rustfs down"))
|
||||
.thenReturn("rustfs:task-parsed/similar-asin/20001/payload.json");
|
||||
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/cfg-fail.xlsx"));
|
||||
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-recovered.xlsx");
|
||||
assertEquals(120, vo.getAcceptedRows());
|
||||
assertEquals(100, vo.getItems().size());
|
||||
// 配置对象默认值校验:新实例默认 100,处于 [1, 1000] 边界内
|
||||
SimilarAsinProperties defaults = new SimilarAsinProperties();
|
||||
assertNotNull(defaults.getParseResponsePreviewLimit());
|
||||
int previewLimit = defaults.getParseResponsePreviewLimit();
|
||||
assertTrue(previewLimit >= 1 && previewLimit <= 1000,
|
||||
"默认预览上限必须在 [1, 1000] 内,实际 " + previewLimit);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user