From 1b480f915f39becd0672b5833e05542cd1ab32ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 14 Sep 2026 09:40:56 +0800 Subject: [PATCH] =?UTF-8?q?test(G4):=20=E5=AE=9A=E6=97=B6=E5=8C=B9?= =?UTF-8?q?=E9=85=8D/=E6=A0=BC=E5=BC=8F=E8=BD=AC=E6=8D=A2/=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=8B=86=E5=88=86=20=E8=A1=A5=2027=20=E4=B8=AA?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95=EF=BC=88=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=AD=A4=E5=89=8D=E5=9D=87=E9=9B=B6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=96=87=E4=BB=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShopMatchResolveService 15:候选越权与幂等、匹配去重保序、国家偏好默认顺序与坏 JSON 兜底 - ConvertTemplateService 8:内置模板禁删(软禁用)、导入命名/后缀补全、设为默认时清掉其它默认位 - SplitRunService 4:下载/删除历史必须属于本人且模块匹配(含无结果文件不可下载) 至此审查点名的 6 个零测试模块全部有测试文件。 --- .../service/ConvertTemplateServiceTest.java | 171 ++++++++++++ .../service/ShopMatchResolveServiceTest.java | 264 ++++++++++++++++++ .../split/service/SplitRunServiceTest.java | 107 +++++++ 3 files changed, 542 insertions(+) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateServiceTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchResolveServiceTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/split/service/SplitRunServiceTest.java diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateServiceTest.java new file mode 100644 index 00000000..8c56f6bb --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateServiceTest.java @@ -0,0 +1,171 @@ +package com.nanri.aiimage.modules.convert.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.convert.mapper.ConvertTemplateMapper; +import com.nanri.aiimage.modules.convert.model.dto.ConvertTemplateImportRequest; +import com.nanri.aiimage.modules.convert.model.entity.ConvertTemplateEntity; +import com.nanri.aiimage.modules.convert.model.vo.ConvertTemplateVo; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +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.ArgumentMatchers.any; +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; + +/** + * 格式转换的模板管理(2026-09 审查 G4:该模块此前零测试文件)。 + * + *

重点:内置模板不可删(软禁用语义)、导入的自定义模板命名与文件名补全、 + * 设为默认时其余模板的默认位必须被清掉。 + */ +class ConvertTemplateServiceTest { + + @BeforeAll + static void initializeMybatisMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + ConvertTemplateEntity.class); + } + + private final ConvertTemplateMapper templateMapper = mock(ConvertTemplateMapper.class); + private final ConvertTemplateService service = new ConvertTemplateService(templateMapper); + + private static ConvertTemplateEntity template(long id, String code, int isDefault, int builtIn) { + ConvertTemplateEntity entity = new ConvertTemplateEntity(); + entity.setId(id); + entity.setTemplateCode(code); + entity.setTemplateName(code + "模板"); + entity.setOutputFilename(code + ".txt"); + entity.setIsDefault(isDefault); + entity.setBuiltIn(builtIn); + entity.setEnabled(1); + return entity; + } + + @Test + @SuppressWarnings("unchecked") + void listEnabledTemplatesKeepsMapperOrder() { + when(templateMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(template(1L, "builtin_offer", 1, 1), template(2L, "user_1", 0, 0))); + + List templates = service.listEnabledTemplates(); + + assertEquals(2, templates.size()); + assertEquals("builtin_offer", templates.get(0).getTemplateCode()); + assertTrue(templates.get(0).getIsDefault()); + assertEquals("user_1", templates.get(1).getTemplateCode()); + } + + @Test + @SuppressWarnings("unchecked") + void getByCodeRejectsMissingOrDisabledTemplate() { + when(templateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + BusinessException exception = assertThrows(BusinessException.class, () -> service.getByCode("gone")); + + assertEquals("模板不存在或已禁用", exception.getMessage()); + } + + @Test + void importTemplateRejectsBlankContent() { + ConvertTemplateImportRequest request = new ConvertTemplateImportRequest(); + request.setTemplateName("我的模板"); + request.setTemplateContent(" "); + + assertThrows(BusinessException.class, () -> service.importTemplate(request)); + verify(templateMapper, never()).insert(any(ConvertTemplateEntity.class)); + } + + @Test + void importTemplateGeneratesUserCodeAndTxtFilename() { + ConvertTemplateImportRequest request = new ConvertTemplateImportRequest(); + request.setTemplateName("我的模板"); + request.setTemplateContent("TemplateType=Offer\tVersion=1.4\n"); + + ConvertTemplateVo vo = service.importTemplate(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ConvertTemplateEntity.class); + verify(templateMapper).insert(captor.capture()); + ConvertTemplateEntity saved = captor.getValue(); + assertTrue(saved.getTemplateCode().startsWith("user_"), "自定义模板编码带 user_ 前缀: " + saved.getTemplateCode()); + assertEquals("我的模板.txt", saved.getOutputFilename(), "文件名补全 .txt 后缀"); + assertEquals(0, saved.getBuiltIn(), "导入的模板不是内置模板"); + assertEquals(0, saved.getIsDefault(), "导入不自动成为默认模板"); + assertEquals(saved.getTemplateCode(), vo.getTemplateCode()); + } + + @Test + void importTemplateKeepsGivenTxtSuffixAndFallsBackToDefaultName() { + ConvertTemplateImportRequest request = new ConvertTemplateImportRequest(); + request.setTemplateName("自定义.txt"); + request.setTemplateContent("x"); + + service.importTemplate(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ConvertTemplateEntity.class); + verify(templateMapper).insert(captor.capture()); + assertEquals("自定义.txt", captor.getValue().getOutputFilename(), "已有 .txt 后缀不重复追加"); + + ConvertTemplateImportRequest unnamed = new ConvertTemplateImportRequest(); + unnamed.setTemplateName(" "); + unnamed.setTemplateContent("x"); + service.importTemplate(unnamed); + + ArgumentCaptor second = ArgumentCaptor.forClass(ConvertTemplateEntity.class); + verify(templateMapper, times(2)).insert(second.capture()); + assertEquals("自定义 Offer 模板.txt", second.getAllValues().get(1).getOutputFilename()); + } + + @Test + @SuppressWarnings("unchecked") + void setDefaultTemplateClearsOthers() { + ConvertTemplateEntity target = template(2L, "user_1", 0, 0); + ConvertTemplateEntity other = template(3L, "user_2", 1, 0); + when(templateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(target); + when(templateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(target, other)); + + service.setDefaultTemplate("user_1"); + + assertEquals(1, target.getIsDefault(), "目标模板置为默认"); + assertEquals(0, other.getIsDefault(), "其余模板的默认位必须清掉,否则会同时存在多个默认模板"); + verify(templateMapper, times(2)).updateById(any(ConvertTemplateEntity.class)); + } + + @Test + @SuppressWarnings("unchecked") + void deleteTemplateRejectsBuiltIn() { + when(templateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(template(1L, "builtin_offer", 1, 1)); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.deleteTemplate("builtin_offer")); + + assertEquals("内置模板不允许删除", exception.getMessage()); + verify(templateMapper, never()).updateById(any(ConvertTemplateEntity.class)); + } + + @Test + @SuppressWarnings("unchecked") + void deleteTemplateSoftDisablesInsteadOfRemoving() { + ConvertTemplateEntity userTemplate = template(5L, "user_5", 0, 0); + when(templateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(userTemplate); + + service.deleteTemplate("user_5"); + + assertEquals(0, userTemplate.getEnabled(), "删除是软禁用(enabled=0),保留历史可追溯"); + verify(templateMapper).updateById(userTemplate); + verify(templateMapper, never()).deleteById(any(Long.class)); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchResolveServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchResolveServiceTest.java new file mode 100644 index 00000000..b916afaf --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchResolveServiceTest.java @@ -0,0 +1,264 @@ +package com.nanri.aiimage.modules.shopmatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.common.model.vo.ProductRiskCandidateVo; +import com.nanri.aiimage.common.model.vo.ProductRiskCountryPreferenceVo; +import com.nanri.aiimage.common.model.vo.ProductRiskMatchShopsVo; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; +import com.nanri.aiimage.modules.shopmatch.mapper.ShopMatchCountryPrefMapper; +import com.nanri.aiimage.modules.shopmatch.mapper.ShopMatchShopCandidateMapper; +import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchCountryPrefEntity; +import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchShopCandidateEntity; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 定时匹配工具的备选店铺与国家偏好(2026-09 审查 G4:该模块此前零测试文件)。 + * + *

重点:候选增删的越权保护与幂等、匹配去重保序、国家偏好的默认顺序与坏数据兜底。 + */ +class ShopMatchResolveServiceTest { + + @BeforeAll + static void initializeMybatisMetadata() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, ShopMatchShopCandidateEntity.class); + TableInfoHelper.initTableInfo(assistant, ShopMatchCountryPrefEntity.class); + } + + private final ShopMatchShopCandidateMapper candidateMapper = mock(ShopMatchShopCandidateMapper.class); + private final ShopMatchCountryPrefMapper countryPrefMapper = mock(ShopMatchCountryPrefMapper.class); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ZiniaoShopSwitchService shopSwitchService = mock(ZiniaoShopSwitchService.class); + + private ShopMatchResolveService service; + + @BeforeEach + void setUp() { + service = new ShopMatchResolveService(candidateMapper, countryPrefMapper, objectMapper, shopSwitchService); + when(shopSwitchService.normalizeShopName(anyString())).thenAnswer(invocation -> + ((String) invocation.getArgument(0)).trim()); + } + + private static ProductRiskCandidateAddRequest addRequest(Long userId, String shopName) { + ProductRiskCandidateAddRequest request = new ProductRiskCandidateAddRequest(); + request.setUserId(userId); + request.setShopName(shopName); + return request; + } + + private static ProductRiskCountryPreferenceSaveRequest prefRequest(Long userId, List codes) { + ProductRiskCountryPreferenceSaveRequest request = new ProductRiskCountryPreferenceSaveRequest(); + request.setUserId(userId); + request.setCountryCodes(codes); + return request; + } + + private static ZiniaoShopMatchResultVo matched(String shopName) { + ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo(); + vo.setMatched(true); + vo.setShopName(shopName); + vo.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_MATCHED); + return vo; + } + + @Test + void listCandidatesRejectsInvalidUser() { + assertThrows(BusinessException.class, () -> service.listCandidates(null)); + assertThrows(BusinessException.class, () -> service.listCandidates(0L)); + } + + @Test + @SuppressWarnings("unchecked") + void listCandidatesMapsRowsToViews() { + ShopMatchShopCandidateEntity row = new ShopMatchShopCandidateEntity(); + row.setId(21L); + row.setUserId(23L); + row.setShopName("匹配店铺"); + row.setCreatedAt(LocalDateTime.now()); + when(candidateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(row)); + + List views = service.listCandidates(23L); + + assertEquals(1, views.size()); + assertEquals(21L, views.getFirst().getId()); + assertEquals("匹配店铺", views.getFirst().getShopName()); + } + + @Test + void addCandidateRejectsBlankShopName() { + when(shopSwitchService.normalizeShopName(" ")).thenReturn(""); + + assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, " "))); + verify(candidateMapper, never()).insert(any(ShopMatchShopCandidateEntity.class)); + } + + @Test + void addCandidateRejectsWhenIndexMisses() { + ZiniaoShopMatchResultVo miss = new ZiniaoShopMatchResultVo(); + miss.setMatched(false); + when(shopSwitchService.findIndexedStoreByName("未收录", false)).thenReturn(miss); + + assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, "未收录"))); + verify(candidateMapper, never()).insert(any(ShopMatchShopCandidateEntity.class)); + } + + @Test + void addCandidateRejectsConflictStatus() { + ZiniaoShopMatchResultVo conflict = matched("同名店铺"); + conflict.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_CONFLICT); + when(shopSwitchService.findIndexedStoreByName("同名店铺", false)).thenReturn(conflict); + + assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, "同名店铺"))); + } + + @Test + @SuppressWarnings("unchecked") + void addCandidateIsIdempotentForExistingShop() { + when(shopSwitchService.findIndexedStoreByName("已存在", false)).thenReturn(matched("已存在")); + ShopMatchShopCandidateEntity existing = new ShopMatchShopCandidateEntity(); + existing.setId(66L); + existing.setUserId(23L); + existing.setShopName("已存在"); + existing.setCreatedAt(LocalDateTime.now()); + when(candidateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing); + + ProductRiskCandidateVo vo = service.addCandidate(addRequest(23L, " 已存在 ")); + + assertEquals(66L, vo.getId()); + verify(candidateMapper, never()).insert(any(ShopMatchShopCandidateEntity.class)); + } + + @Test + void deleteCandidateValidatesIdAndOwnership() { + assertThrows(BusinessException.class, () -> service.deleteCandidate(23L, null)); + assertThrows(BusinessException.class, () -> service.deleteCandidate(23L, 0L)); + + ShopMatchShopCandidateEntity other = new ShopMatchShopCandidateEntity(); + other.setId(8L); + other.setUserId(77L); + when(candidateMapper.selectById(8L)).thenReturn(other); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.deleteCandidate(23L, 8L)); + + assertEquals("记录不存在", exception.getMessage(), "他人记录与不存在同文案,避免探测"); + verify(candidateMapper, never()).deleteById(any(Long.class)); + } + + @Test + void deleteCandidateRemovesOwnRecord() { + ShopMatchShopCandidateEntity own = new ShopMatchShopCandidateEntity(); + own.setId(8L); + own.setUserId(23L); + when(candidateMapper.selectById(8L)).thenReturn(own); + + service.deleteCandidate(23L, 8L); + + verify(candidateMapper).deleteById(8L); + } + + @Test + void matchShopsRejectsEmptyAfterNormalization() { + ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest(); + request.setUserId(23L); + request.setShopNames(List.of(" ")); + + assertThrows(BusinessException.class, () -> service.matchShops(request)); + } + + @Test + void matchShopsDeduplicatesKeepingFirstOccurrenceOrder() { + ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest(); + request.setUserId(23L); + request.setShopNames(List.of(" A ", "B", "A")); + when(shopSwitchService.findIndexedStoreByName(anyString(), anyBoolean())).thenAnswer(invocation -> + matched((String) invocation.getArgument(0))); + + ProductRiskMatchShopsVo vo = service.matchShops(request); + + assertEquals(2, vo.getItems().size()); + assertEquals("A", vo.getItems().get(0).getShopName()); + assertEquals("B", vo.getItems().get(1).getShopName()); + } + + @Test + void countryPreferenceFallsBackToDefaultOrderWhenAbsent() { + when(countryPrefMapper.selectById(23L)).thenReturn(null); + + ProductRiskCountryPreferenceVo vo = service.getCountryPreference(23L); + + assertEquals(ShopMatchResolveService.DEFAULT_COUNTRY_PREFERENCE_ORDER, vo.getCountryCodes()); + } + + @Test + void countryPreferenceFallsBackToDefaultOrderOnBrokenJson() { + ShopMatchCountryPrefEntity broken = new ShopMatchCountryPrefEntity(); + broken.setUserId(23L); + broken.setCountryCodesJson("{not-json"); + when(countryPrefMapper.selectById(23L)).thenReturn(broken); + + ProductRiskCountryPreferenceVo vo = service.getCountryPreference(23L); + + assertEquals(ShopMatchResolveService.DEFAULT_COUNTRY_PREFERENCE_ORDER, vo.getCountryCodes(), + "坏数据不能让页面空白,回退默认顺序"); + } + + @Test + void countryPreferenceReadsStoredOrder() { + ShopMatchCountryPrefEntity stored = new ShopMatchCountryPrefEntity(); + stored.setUserId(23L); + stored.setCountryCodesJson("[\"UK\",\"DE\"]"); + when(countryPrefMapper.selectById(23L)).thenReturn(stored); + + ProductRiskCountryPreferenceVo vo = service.getCountryPreference(23L); + + assertEquals(List.of("UK", "DE"), vo.getCountryCodes(), "已保存顺序优先于默认顺序"); + } + + @Test + void saveCountryPreferenceRejectsEmptySelection() { + assertThrows(BusinessException.class, () -> service.saveCountryPreference(prefRequest(23L, List.of()))); + assertThrows(BusinessException.class, () -> service.saveCountryPreference(prefRequest(23L, null))); + } + + @Test + void saveCountryPreferenceInsertsThenUpdates() { + when(countryPrefMapper.selectById(23L)).thenReturn(null); + service.saveCountryPreference(prefRequest(23L, List.of("UK", "DE"))); + verify(countryPrefMapper).insert(any(ShopMatchCountryPrefEntity.class)); + + ShopMatchCountryPrefEntity existing = new ShopMatchCountryPrefEntity(); + existing.setUserId(23L); + existing.setCountryCodesJson("[\"DE\"]"); + when(countryPrefMapper.selectById(23L)).thenReturn(existing); + ProductRiskCountryPreferenceVo updated = service.saveCountryPreference(prefRequest(23L, List.of("DE", "UK"))); + + assertEquals(List.of("DE", "UK"), updated.getCountryCodes()); + verify(countryPrefMapper).updateById(existing); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/split/service/SplitRunServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/split/service/SplitRunServiceTest.java new file mode 100644 index 00000000..a3c60ef4 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/split/service/SplitRunServiceTest.java @@ -0,0 +1,107 @@ +package com.nanri.aiimage.modules.split.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.StorageProperties; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 数据拆分的结果文件归属校验(2026-09 审查 G4:该模块此前零测试文件)。 + * + *

只覆盖"能安全断言、且与安全相关"的部分:下载与删除的历史记录必须属于本人且模块匹配。 + * `run(...)` 依赖真实 workbook 与 OSS 上传,另需集成环境,此处不构造。 + */ +class SplitRunServiceTest { + + private static final String MODULE_TYPE = "SPLIT"; + + @BeforeAll + static void initializeMybatisMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + FileResultEntity.class); + } + + private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class); + private final FileResultMapper fileResultMapper = mock(FileResultMapper.class); + private final StorageProperties storageProperties = mock(StorageProperties.class); + private final OssStorageService ossStorageService = mock(OssStorageService.class); + + private SplitRunService service; + + @BeforeEach + void setUp() { + service = new SplitRunService(fileTaskMapper, fileResultMapper, storageProperties, ossStorageService); + } + + private static FileResultEntity result(Long id, String moduleType, Long userId, String url) { + FileResultEntity entity = new FileResultEntity(); + entity.setId(id); + entity.setModuleType(moduleType); + entity.setUserId(userId); + entity.setResultFileUrl(url); + return entity; + } + + @Test + void deleteHistoryRejectsMissingOrForeignOrOtherModuleRecord() { + when(fileResultMapper.selectById(99L)).thenReturn(null); + assertThrows(BusinessException.class, () -> service.deleteHistory(99L, 23L), "记录不存在"); + + when(fileResultMapper.selectById(100L)).thenReturn(result(100L, MODULE_TYPE, 88L, "oss/x.xlsx")); + assertThrows(BusinessException.class, () -> service.deleteHistory(100L, 23L), "他人记录不可删"); + + when(fileResultMapper.selectById(101L)).thenReturn(result(101L, "DEDUPE", 23L, "oss/x.xlsx")); + assertThrows(BusinessException.class, () -> service.deleteHistory(101L, 23L), "跨模块记录不可删"); + + verify(fileResultMapper, never()).deleteById(anyLong()); + } + + @Test + void deleteHistoryRemovesOwnSplitRecord() { + when(fileResultMapper.selectById(102L)).thenReturn(result(102L, MODULE_TYPE, 23L, "oss/x.xlsx")); + + service.deleteHistory(102L, 23L); + + verify(fileResultMapper).deleteById(102L); + } + + @Test + void downloadUrlRejectsForeignOrMissingFile() { + when(fileResultMapper.selectById(200L)).thenReturn(result(200L, MODULE_TYPE, 88L, "oss/x.xlsx")); + assertThrows(BusinessException.class, () -> service.getResultDownloadUrl(200L, 23L)); + + when(fileResultMapper.selectById(201L)).thenReturn(result(201L, MODULE_TYPE, 23L, null)); + assertThrows(BusinessException.class, () -> service.getResultDownloadUrl(201L, 23L), "无结果文件时不可下载"); + + when(fileResultMapper.selectById(202L)).thenReturn(result(202L, "CONVERT", 23L, "oss/x.xlsx")); + assertThrows(BusinessException.class, () -> service.getResultDownloadUrl(202L, 23L)); + + verify(ossStorageService, never()).generateFreshDownloadUrl(any()); + } + + @Test + void downloadUrlReturnsFreshSignedUrlForOwnRecord() { + when(fileResultMapper.selectById(203L)).thenReturn(result(203L, MODULE_TYPE, 23L, "oss/split.xlsx")); + when(ossStorageService.generateFreshDownloadUrl("oss/split.xlsx")).thenReturn("https://oss.example/split.xlsx?sig=1"); + + assertEquals("https://oss.example/split.xlsx?sig=1", service.getResultDownloadUrl(203L, 23L)); + } +}