处理后台新需求
Build Backend JAR / build (push) Failing after 16m58s

This commit is contained in:
supernijia
2026-08-09 20:08:39 +08:00
parent f7e26ee266
commit 38216a1885
42 changed files with 3425 additions and 424 deletions
@@ -13,6 +13,7 @@ import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataPageVo;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -65,6 +66,7 @@ class DedupeTotalDataServiceTest {
void createRecordsUploaderIdentity() {
AdminUserEntity uploader = user(23L, "normal", "member-a");
when(adminUserMapper.selectById(23L)).thenReturn(uploader);
stubWritableGroup(23L, 7L);
when(dedupeTotalDataMapper.selectOne(any())).thenReturn(null);
when(dedupeTotalDataMapper.insert(any(DedupeTotalDataEntity.class))).thenAnswer(invocation -> {
DedupeTotalDataEntity entity = invocation.getArgument(0);
@@ -75,6 +77,7 @@ class DedupeTotalDataServiceTest {
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
entity.setId(91L);
entity.setDataValue("B012345678");
entity.setGroupId(7L);
entity.setUploaderUserId(23L);
entity.setUploaderUsername("member-a");
return entity;
@@ -82,6 +85,7 @@ class DedupeTotalDataServiceTest {
DedupeTotalDataCreateRequest request = new DedupeTotalDataCreateRequest();
request.setDataValue(" b012345678 ");
request.setGroupId(7L);
DedupeTotalDataItemVo item = service.create(request, 23L);
ArgumentCaptor<DedupeTotalDataEntity> captor = ArgumentCaptor.forClass(DedupeTotalDataEntity.class);
@@ -89,7 +93,10 @@ class DedupeTotalDataServiceTest {
assertEquals(23L, captor.getValue().getUploaderUserId());
assertEquals("member-a", captor.getValue().getUploaderUsername());
assertEquals("B012345678", captor.getValue().getDataValue());
assertEquals(7L, captor.getValue().getGroupId());
assertEquals("member-a", item.getUsername());
assertEquals(7L, item.getGroupId());
assertEquals("group-7", item.getGroupName());
}
@Test
@@ -117,6 +124,39 @@ class DedupeTotalDataServiceTest {
verify(dedupeTotalDataMapper, never()).deleteById(any(Long.class));
}
@Test
void memberCanDeleteDataInAccessibleGroup() {
when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a"));
when(shopManageGroupMapper.selectAccessibleGroupIds(23L)).thenReturn(List.of(7L));
when(shopManageGroupMapper.selectUserIdsByGroupIds(List.of(7L))).thenReturn(List.of(23L));
when(shopManageGroupMapper.selectManagedMemberUserIds(23L)).thenReturn(List.of());
DedupeTotalDataEntity entity = data(91L, 99L);
entity.setGroupId(7L);
when(dedupeTotalDataMapper.selectById(91L)).thenReturn(entity);
service.delete(91L, 23L);
verify(dedupeTotalDataMapper).deleteById(91L);
}
@Test
void memberCannotDeleteDataInAnotherGroup() {
when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a"));
when(shopManageGroupMapper.selectAccessibleGroupIds(23L)).thenReturn(List.of(7L));
when(shopManageGroupMapper.selectUserIdsByGroupIds(List.of(7L))).thenReturn(List.of(23L));
when(shopManageGroupMapper.selectManagedMemberUserIds(23L)).thenReturn(List.of());
DedupeTotalDataEntity entity = data(91L, 23L);
entity.setGroupId(8L);
when(dedupeTotalDataMapper.selectById(91L)).thenReturn(entity);
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.delete(91L, 23L));
assertEquals(403, exception.getStatusCode().value());
verify(dedupeTotalDataMapper, never()).deleteById(any(Long.class));
}
@Test
void superAdminCanDeleteAnyUsersData() {
when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root"));
@@ -131,10 +171,11 @@ class DedupeTotalDataServiceTest {
@Test
void duplicateExcelValueKeepsOriginalUploader() throws Exception {
when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a"));
stubWritableGroup(23L, 7L);
when(dedupeTotalDataMapper.selectOne(any())).thenReturn(data(91L, 99L));
MockMultipartFile file = asinWorkbook("B012345678");
var result = service.importFromExcel(file, 23L);
var result = service.importFromExcel(file, 7L, 23L);
assertEquals(0, result.getInsertedCount());
assertEquals(1, result.getSkippedCount());
@@ -144,12 +185,13 @@ class DedupeTotalDataServiceTest {
@Test
void concurrentDuplicateDuringImportIsSkipped() throws Exception {
when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a"));
stubWritableGroup(23L, 7L);
when(dedupeTotalDataMapper.selectOne(any())).thenReturn(null);
when(transactionManager.getTransaction(any())).thenReturn(transactionStatus);
when(dedupeTotalDataMapper.insert(any(DedupeTotalDataEntity.class)))
.thenThrow(new DuplicateKeyException("duplicate"));
var result = service.importFromExcel(asinWorkbook("b012345678"), 23L);
var result = service.importFromExcel(asinWorkbook("b012345678"), 7L, 23L);
assertEquals(0, result.getInsertedCount());
assertEquals(1, result.getSkippedCount());
@@ -172,6 +214,18 @@ class DedupeTotalDataServiceTest {
verify(dedupeTotalDataMapper).selectList(any());
}
@Test
void pageSupportsUngroupedHistoricalRows() {
when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root"));
when(dedupeTotalDataMapper.selectCount(any())).thenReturn(1L);
when(dedupeTotalDataMapper.selectList(any())).thenReturn(List.of(data(91L, 23L)));
DedupeTotalDataPageVo page = service.page(1, 15, "", "", null, null, 1L);
assertEquals(1L, page.getTotal());
assertEquals("", page.getItems().getFirst().getGroupName());
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void pageUsesInclusiveDateRange() {
@@ -245,7 +299,8 @@ class DedupeTotalDataServiceTest {
assertEquals("ASIN值", sheet.getRow(0).getCell(1).getStringCellValue());
assertEquals("B012345678", sheet.getRow(1).getCell(1).getStringCellValue());
assertEquals("member-a", sheet.getRow(1).getCell(2).getStringCellValue());
assertEquals("2026-07-20 12:30:00", sheet.getRow(1).getCell(3).getStringCellValue());
assertEquals("", sheet.getRow(1).getCell(3).getStringCellValue());
assertEquals("2026-07-20 12:30:00", sheet.getRow(1).getCell(4).getStringCellValue());
}
verify(shopManageGroupMapper).selectManagedMemberUserIds(10L);
@@ -262,27 +317,70 @@ class DedupeTotalDataServiceTest {
verify(dedupeTotalDataMapper, never()).selectList(any());
}
@Test
void importProgressAllowsAccessibleGroup() {
DedupeTotalDataImportProgressVo progress = registerImportProgress("same-group", 24L, 7L);
when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a"));
when(shopManageGroupMapper.selectAccessibleGroupIds(23L)).thenReturn(List.of(7L));
when(shopManageGroupMapper.selectUserIdsByGroupIds(List.of(7L))).thenReturn(List.of(23L, 24L));
when(shopManageGroupMapper.selectManagedMemberUserIds(23L)).thenReturn(List.of());
assertEquals(progress, service.getImportProgress("same-group", 23L));
}
@Test
void importProgressRejectsVisibleOwnerFromAnotherGroup() {
registerImportProgress("other-group", 24L, 8L);
when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a"));
when(shopManageGroupMapper.selectAccessibleGroupIds(23L)).thenReturn(List.of(7L));
when(shopManageGroupMapper.selectUserIdsByGroupIds(List.of(7L))).thenReturn(List.of(23L, 24L));
when(shopManageGroupMapper.selectManagedMemberUserIds(23L)).thenReturn(List.of());
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.getImportProgress("other-group", 23L));
assertEquals(403, exception.getStatusCode().value());
}
@Test
@SuppressWarnings("unchecked")
void expiredCompletedProgressIsRemovedOnNextLookup() {
Map<String, DedupeTotalDataImportProgressVo> progressMap =
(Map<String, DedupeTotalDataImportProgressVo>) ReflectionTestUtils.getField(service, "importProgressMap");
Map<String, Long> ownerMap = (Map<String, Long>) ReflectionTestUtils.getField(service, "importOwnerMap");
Map<String, Long> groupMap = (Map<String, Long>) ReflectionTestUtils.getField(service, "importGroupMap");
Map<String, Long> completedAtMap =
(Map<String, Long>) ReflectionTestUtils.getField(service, "importCompletedAtMap");
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
progress.setStatus("success");
progressMap.put("expired", progress);
ownerMap.put("expired", 23L);
groupMap.put("expired", 7L);
completedAtMap.put("expired", System.currentTimeMillis() - (2 * 60 * 60 * 1000L));
assertThrows(BusinessException.class, () -> service.getImportProgress("expired", 23L));
assertFalse(progressMap.containsKey("expired"));
assertFalse(ownerMap.containsKey("expired"));
assertFalse(groupMap.containsKey("expired"));
assertFalse(completedAtMap.containsKey("expired"));
}
@SuppressWarnings("unchecked")
private DedupeTotalDataImportProgressVo registerImportProgress(String importId, Long ownerId, Long groupId) {
Map<String, DedupeTotalDataImportProgressVo> progressMap =
(Map<String, DedupeTotalDataImportProgressVo>) ReflectionTestUtils.getField(service, "importProgressMap");
Map<String, Long> ownerMap = (Map<String, Long>) ReflectionTestUtils.getField(service, "importOwnerMap");
Map<String, Long> groupMap = (Map<String, Long>) ReflectionTestUtils.getField(service, "importGroupMap");
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
progress.setStatus("running");
progressMap.put(importId, progress);
ownerMap.put(importId, ownerId);
groupMap.put(importId, groupId);
return progress;
}
private AdminUserEntity user(Long id, String role, String username) {
AdminUserEntity user = new AdminUserEntity();
user.setId(id);
@@ -301,6 +399,21 @@ class DedupeTotalDataServiceTest {
return entity;
}
private void stubWritableGroup(Long operatorId, Long groupId) {
when(shopManageGroupMapper.selectAccessibleGroupIds(operatorId)).thenReturn(List.of(groupId));
when(shopManageGroupMapper.selectUserIdsByGroupIds(List.of(groupId))).thenReturn(List.of(operatorId));
when(shopManageGroupMapper.selectManagedMemberUserIds(operatorId)).thenReturn(List.of());
when(shopManageGroupMapper.selectById(groupId)).thenReturn(group(groupId));
}
private ShopManageGroupEntity group(Long id) {
ShopManageGroupEntity group = new ShopManageGroupEntity();
group.setId(id);
group.setGroupName("group-" + id);
group.setCreatedById(23L);
return group;
}
private MockMultipartFile asinWorkbook(String asin) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
@@ -0,0 +1,108 @@
package com.nanri.aiimage.modules.invalidasin.controller;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataPageVo;
import com.nanri.aiimage.modules.invalidasin.service.InvalidAsinDataService;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InvalidAsinDataControllerTest {
@Mock
private InvalidAsinDataService invalidAsinDataService;
@Mock
private AdminAuthSupport adminAuthSupport;
@Mock
private PermissionMenuService permissionMenuService;
@InjectMocks
private InvalidAsinDataController controller;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(controller, "internalToken", "test-internal-token");
}
@Test
void trustedFlaskProxyUsesTheDatabaseUserInsteadOfRequestRole() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("X-Internal-Token", "test-internal-token");
request.addParameter("operatorId", "7");
request.addParameter("superAdmin", "true");
AdminUserEntity operator = user(7L, "normal");
when(adminAuthSupport.requireUser(request)).thenThrow(new BusinessException(401, "未登录"));
when(permissionMenuService.requireUserOperator(7L)).thenReturn(operator);
when(adminAuthSupport.currentRole(operator)).thenReturn(null);
when(permissionMenuService.getUserColumnPermissions(7L, "admin"))
.thenReturn(List.of(invalidAsinDataPermission()));
when(invalidAsinDataService.page(1L, 15L, "", 3L, 7L, false))
.thenReturn(new InvalidAsinDataPageVo());
controller.page(1L, 15L, "", 3L, request);
verify(invalidAsinDataService).page(1L, 15L, "", 3L, 7L, false);
}
@Test
void directRequestCannotForgeSuperAdminWithoutTrustedAuthentication() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addParameter("operatorId", "1");
request.addParameter("superAdmin", "true");
when(adminAuthSupport.requireUser(request)).thenThrow(new BusinessException(401, "未登录"));
assertThrows(BusinessException.class, () -> controller.page(1L, 15L, "", 3L, request));
verifyNoInteractions(invalidAsinDataService);
verify(permissionMenuService, never()).requireUserOperator(any());
}
@Test
void authenticatedSuperAdminRoleComesFromTheServerUser() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addParameter("superAdmin", "false");
AdminUserEntity operator = user(1L, "super_admin");
when(adminAuthSupport.requireUser(request)).thenReturn(operator);
when(adminAuthSupport.currentRole(operator)).thenReturn("super_admin");
when(invalidAsinDataService.page(1L, 15L, "", 3L, 1L, true))
.thenReturn(new InvalidAsinDataPageVo());
controller.page(1L, 15L, "", 3L, request);
verify(invalidAsinDataService).page(1L, 15L, "", 3L, 1L, true);
verify(permissionMenuService, never()).getUserColumnPermissions(eq(1L), eq("admin"));
}
private AdminUserEntity user(Long id, String role) {
AdminUserEntity user = new AdminUserEntity();
user.setId(id);
user.setRole(role);
return user;
}
private PermissionMenuItemVo invalidAsinDataPermission() {
PermissionMenuItemVo permission = new PermissionMenuItemVo();
permission.setColumnKey("admin_invalid_asin_data");
permission.setRoutePath("invalid-asin-data");
return permission;
}
}
@@ -0,0 +1,250 @@
package com.nanri.aiimage.modules.invalidasin.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.invalidasin.mapper.InvalidAsinDataMapper;
import com.nanri.aiimage.modules.invalidasin.model.dto.InvalidAsinDataCreateRequest;
import com.nanri.aiimage.modules.invalidasin.model.dto.InvalidAsinDataUpdateRequest;
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataItemVo;
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataPageVo;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.service.ShopManageGroupService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
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.assertNull;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InvalidAsinDataServiceTest {
@Mock
private InvalidAsinDataMapper invalidAsinDataMapper;
@Mock
private ShopManageGroupService shopManageGroupService;
@InjectMocks
private InvalidAsinDataService service;
@Test
void createManualRecordUsesAccessibleGroup() {
InvalidAsinDataCreateRequest request = new InvalidAsinDataCreateRequest();
request.setDataValue("B012345678");
request.setBrand("Acme");
request.setGroupId(9L);
ShopManageGroupEntity group = group(9L, "group-a");
InvalidAsinDataEntity saved = data(91L, "MANUAL", 9L);
when(shopManageGroupService.listAccessibleGroupIds(7L, false)).thenReturn(Set.of(12L, 9L));
when(shopManageGroupService.getAccessibleById(9L, 7L, false)).thenReturn(group);
when(invalidAsinDataMapper.selectOne(any())).thenReturn(null);
when(invalidAsinDataMapper.insert(any(InvalidAsinDataEntity.class))).thenAnswer(invocation -> {
invocation.getArgument(0, InvalidAsinDataEntity.class).setId(91L);
return 1;
});
when(invalidAsinDataMapper.selectById(91L)).thenReturn(saved);
InvalidAsinDataItemVo item = service.create(request, 7L, false);
ArgumentCaptor<InvalidAsinDataEntity> captor = ArgumentCaptor.forClass(InvalidAsinDataEntity.class);
verify(invalidAsinDataMapper).insert(captor.capture());
assertEquals("B012345678", captor.getValue().getDataValue());
assertEquals("acme", captor.getValue().getBrand());
assertEquals(9L, captor.getValue().getGroupId());
assertEquals("MANUAL", captor.getValue().getRecordSource());
assertEquals("group-a", item.getGroupName());
assertEquals("MANUAL", item.getRecordSource());
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void normalUserPageOnlyQueriesManualRowsInAccessibleGroups() {
InvalidAsinDataEntity manual = data(91L, "MANUAL", 9L);
when(shopManageGroupService.listAccessibleGroupIds(7L, false)).thenReturn(Set.of(10L, 9L));
when(invalidAsinDataMapper.selectCount(any())).thenReturn(1L);
when(invalidAsinDataMapper.selectList(any())).thenReturn(List.of(manual));
when(shopManageGroupService.buildGroupNameMap(any())).thenReturn(Map.of(9L, "group-a"));
InvalidAsinDataPageVo page = service.page(1, 15, "", 10L, 7L, false);
ArgumentCaptor<LambdaQueryWrapper<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
verify(invalidAsinDataMapper).selectCount(captor.capture());
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
InvalidAsinDataEntity.class);
String sql = captor.getValue().getSqlSegment();
assertTrue(sql.contains("record_source"));
assertTrue(sql.contains("group_id"));
assertTrue(captor.getValue().getParamNameValuePairs().containsValue("MANUAL"));
assertTrue(captor.getValue().getParamNameValuePairs().containsValue(9L));
assertFalse(captor.getValue().getParamNameValuePairs().containsValue(10L));
assertEquals("group-a", page.getItems().getFirst().getGroupName());
}
@Test
void superAdminPageHandlesRowsWithoutResolvableGroup() {
InvalidAsinDataEntity automatic = data(92L, "AUTO", null);
InvalidAsinDataEntity orphan = data(93L, "MANUAL", 999L);
when(invalidAsinDataMapper.selectCount(any())).thenReturn(1L);
when(invalidAsinDataMapper.selectList(any())).thenReturn(List.of(automatic, orphan));
when(shopManageGroupService.buildGroupNameMap(any())).thenReturn(Map.of());
InvalidAsinDataPageVo page = service.page(1, 15, "", null, 1L, true);
assertEquals(2, page.getItems().size());
assertEquals("AUTO", page.getItems().getFirst().getRecordSource());
assertEquals("", page.getItems().getFirst().getGroupName());
assertEquals("", page.getItems().get(1).getGroupName());
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void superAdminPageFiltersByRequestedGroup() {
when(invalidAsinDataMapper.selectCount(any())).thenReturn(0L);
when(invalidAsinDataMapper.selectList(any())).thenReturn(List.of());
service.page(1, 15, "", 9L, 1L, true);
ArgumentCaptor<LambdaQueryWrapper<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
verify(invalidAsinDataMapper).selectCount(captor.capture());
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
InvalidAsinDataEntity.class);
assertTrue(captor.getValue().getSqlSegment().contains("group_id"));
assertTrue(captor.getValue().getParamNameValuePairs().containsValue(9L));
}
@Test
void normalUserCannotDeleteAutoRecord() {
when(invalidAsinDataMapper.selectById(91L)).thenReturn(data(91L, "AUTO", null));
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.delete(91L, 7L, false));
assertEquals(403, exception.getStatusCode().value());
verify(invalidAsinDataMapper, never()).deleteById(any(Long.class));
verifyNoInteractions(shopManageGroupService);
}
@Test
void normalUserCannotUpdateIntoAnotherAccessibleGroup() {
InvalidAsinDataEntity record = data(91L, "MANUAL", 9L);
InvalidAsinDataUpdateRequest request = new InvalidAsinDataUpdateRequest();
request.setDataValue("B012345678");
request.setBrand("New Brand");
request.setGroupId(10L);
when(invalidAsinDataMapper.selectById(91L)).thenReturn(record);
when(shopManageGroupService.listAccessibleGroupIds(7L, false)).thenReturn(Set.of(10L, 9L));
when(shopManageGroupService.getAccessibleById(9L, 7L, false)).thenReturn(group(9L, "group-a"));
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.update(91L, request, 7L, false));
assertEquals(403, exception.getStatusCode().value());
assertEquals(9L, record.getGroupId());
assertEquals("acme", record.getBrand());
verify(invalidAsinDataMapper, never()).updateById(any(InvalidAsinDataEntity.class));
verify(shopManageGroupService, never()).getAccessibleById(10L, 7L, false);
}
@Test
void normalUserCannotCreateInAnotherAccessibleGroup() {
InvalidAsinDataCreateRequest request = new InvalidAsinDataCreateRequest();
request.setDataValue("B012345678");
request.setBrand("Acme");
request.setGroupId(10L);
when(shopManageGroupService.listAccessibleGroupIds(7L, false)).thenReturn(Set.of(10L, 9L));
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.create(request, 7L, false));
assertEquals(403, exception.getStatusCode().value());
verify(invalidAsinDataMapper, never()).insert(any(InvalidAsinDataEntity.class));
}
@Test
void normalUserCannotDeleteRecordOutsideFixedGroup() {
InvalidAsinDataEntity record = data(91L, "MANUAL", 10L);
when(invalidAsinDataMapper.selectById(91L)).thenReturn(record);
when(shopManageGroupService.listAccessibleGroupIds(7L, false)).thenReturn(Set.of(10L, 9L));
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.delete(91L, 7L, false));
assertEquals(403, exception.getStatusCode().value());
verify(invalidAsinDataMapper, never()).deleteById(any(Long.class));
verify(shopManageGroupService, never()).getAccessibleById(any(Long.class), any(Long.class), org.mockito.ArgumentMatchers.eq(false));
}
@Test
void normalUserCreateRequiresAtLeastOneAccessibleGroup() {
InvalidAsinDataCreateRequest request = new InvalidAsinDataCreateRequest();
request.setDataValue("B012345678");
request.setBrand("Acme");
request.setGroupId(9L);
when(shopManageGroupService.listAccessibleGroupIds(7L, false)).thenReturn(Set.of());
assertThrows(BusinessException.class, () -> service.create(request, 7L, false));
verify(invalidAsinDataMapper, never()).insert(any(InvalidAsinDataEntity.class));
verify(shopManageGroupService, never()).getAccessibleById(any(Long.class), any(Long.class), org.mockito.ArgumentMatchers.eq(false));
}
@Test
void superAdminCanUpdateAutoRecordWithoutGroup() {
InvalidAsinDataEntity record = data(91L, "AUTO", null);
InvalidAsinDataUpdateRequest request = new InvalidAsinDataUpdateRequest();
request.setDataValue("B012345678");
request.setBrand("New Brand");
when(invalidAsinDataMapper.selectById(91L)).thenReturn(record);
when(invalidAsinDataMapper.selectOne(any())).thenReturn(null);
InvalidAsinDataItemVo item = service.update(91L, request, 1L, true);
assertEquals("AUTO", item.getRecordSource());
assertNull(record.getGroupId());
verify(invalidAsinDataMapper).updateById(record);
verifyNoInteractions(shopManageGroupService);
}
private ShopManageGroupEntity group(Long id, String groupName) {
ShopManageGroupEntity group = new ShopManageGroupEntity();
group.setId(id);
group.setGroupName(groupName);
return group;
}
private InvalidAsinDataEntity data(Long id, String source, Long groupId) {
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
entity.setId(id);
entity.setDataValue("B012345678");
entity.setBrand("acme");
entity.setGroupId(groupId);
entity.setRecordSource(source);
return entity;
}
}
@@ -72,6 +72,55 @@ class ShopDataCrawlExcelAssemblyServiceTest {
}
}
@Test
void appendsOnlyTheSelectedCountryAndPreservesExistingPictures() throws Exception {
ShopDataCrawlRowDto firstRow = row("2026-07-25", "B012345678");
firstRow.setCommodityImage("https://m.media-amazon.com/images/I/first.jpg");
ShopDataCrawlRowDto secondRow = row("2026-07-26", "B099999999");
secondRow.setCommodityImage(null);
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
when(imageEmbedder.fetchAndResizeForCache(firstRow.getCommodityImage()))
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
ShopDataCrawlExcelAssemblyService service = new ShopDataCrawlExcelAssemblyService(imageEmbedder);
File base = tempDir.resolve("base.xlsx").toFile();
File output = tempDir.resolve("daily.xlsx").toFile();
service.writeWorkbook(base, List.of(item("UK", firstRow)));
service.appendWorkbook(base, output, List.of(item("DE", secondRow)));
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
assertEquals("B012345678", workbook.getSheetAt(0).getRow(1).getCell(1).getStringCellValue());
assertEquals("B099999999", workbook.getSheetAt(1).getRow(1).getCell(1).getStringCellValue());
assertEquals(1, workbook.getAllPictures().size());
assertEquals(1, workbook.getSheetAt(0).getDrawingPatriarch().getShapes().size());
assertEquals(0, workbook.getSheetAt(2).getLastRowNum());
}
}
private ShopDataCrawlResultItemVo item(String countryCode, ShopDataCrawlRowDto row) {
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
country.setCountry(countryCode);
country.setItems(List.of(row));
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
item.setSuccess(true);
item.setCountryResults(List.of(country));
return item;
}
private ShopDataCrawlRowDto row(String date, String asin) {
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
row.setDate(date);
row.setAsin(asin);
row.setBrand("Example Brand");
row.setInventorySales("11");
row.setSalesRank("22");
row.setPageViews("33");
row.setUnitsSold("44");
row.setPrice("12.50");
row.setRecommendedOffer("12.00");
return row;
}
private byte[] jpegBytes() throws Exception {
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
ByteArrayOutputStream output = new ByteArrayOutputStream();
@@ -92,6 +92,7 @@ class ShopDataCrawlTaskServiceChunkTest {
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@Mock private InstanceMetadata instanceMetadata;
@Mock private ShopDataCrawlDailyFileService dailyFileService;
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private ShopDataCrawlTaskService service;
@@ -122,7 +123,8 @@ class ShopDataCrawlTaskServiceChunkTest {
taskChunkMapper,
taskScopeStateMapper,
transientPayloadStorageService,
instanceMetadata);
instanceMetadata,
dailyFileService);
storedChunks.clear();
storedScopes.clear();
@@ -39,6 +39,7 @@ class ShopDataCrawlTaskServiceOwnerTest {
@Mock com.nanri.aiimage.modules.task.mapper.TaskChunkMapper taskChunkMapper;
@Mock com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper taskScopeStateMapper;
@Mock com.nanri.aiimage.modules.task.service.TransientPayloadStorageService transientPayloadStorageService;
@Mock ShopDataCrawlDailyFileService dailyFileService;
@Spy private final ObjectMapper objectMapper = new ObjectMapper();
@InjectMocks ShopDataCrawlTaskService service;
@@ -5,12 +5,18 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyFileEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyMemberEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
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.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
@@ -20,34 +26,55 @@ import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
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.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDate;
import java.time.LocalDateTime;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
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.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ShopDataCrawlTaskServiceRetentionTest {
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
private static final Long USER_ID = 7L;
private static final Long TASK_ID = 101L;
private static final Long RESULT_ID = 201L;
private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 8, 6);
private static final LocalDateTime BUSINESS_TIME = LocalDateTime.of(2026, 8, 6, 12, 0);
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
}
@Mock private FileTaskMapper fileTaskMapper;
@@ -66,106 +93,302 @@ class ShopDataCrawlTaskServiceRetentionTest {
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@Mock private InstanceMetadata instanceMetadata;
@Spy private final ObjectMapper objectMapper = new ObjectMapper();
@Mock private ShopDataCrawlDailyFileService dailyFileService;
@Spy private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
@InjectMocks private ShopDataCrawlTaskService service;
@Test
void keepsNewestThreePerStableShopAndFallsBackToShopName() {
LocalDateTime now = LocalDateTime.of(2026, 8, 5, 12, 0);
FileResultEntity shopIdOldest = result(11L, 111L, USER_ID, "shop-1", "Renamed Shop",
"result/shop-id-oldest.xlsx", 1, now.minusDays(2));
FileResultEntity shopNameOldest = result(21L, 121L, USER_ID, " ", "Fallback Shop",
"result/shop-name-oldest.xlsx", 1, now.minusDays(4));
private FileTaskEntity task;
private FileResultEntity currentRow;
private ShopDataCrawlResultItemVo snapshot;
private TaskFileJobEntity job;
List<FileResultEntity> rows = List.of(
result(13L, 113L, USER_ID, "shop-1", "Current Name", "result/13.xlsx", 1, now.minusDays(1)),
result(23L, 123L, USER_ID, null, "Fallback Shop", "result/23.xlsx", 1, now.minusDays(2)),
shopIdOldest,
result(14L, 114L, USER_ID, "shop-1", "Current Name", "result/14.xlsx", 1, now),
result(24L, 124L, USER_ID, null, "Fallback Shop", "result/24.xlsx", 1, now),
result(12L, 112L, USER_ID, "shop-1", "Old Name", "result/12.xlsx", 1, now.minusDays(2)),
shopNameOldest,
result(22L, 122L, USER_ID, null, "Fallback Shop", "result/22.xlsx", 1, now.minusDays(3)),
result(1L, 101L, USER_ID, "shop-1", "Current Name", "result/failed.xlsx", 0, now.minusDays(9)),
result(2L, 102L, USER_ID, "shop-1", "Current Name", null, 1, now.minusDays(9)),
result(3L, 103L, 99L, "shop-1", "Current Name", "result/other-user.xlsx", 1, now.minusDays(9)),
result(4L, 104L, USER_ID, "shop-2", "Current Name", "result/other-shop.xlsx", 1, now.minusDays(9)));
@BeforeEach
void setUp() {
task = task();
currentRow = result(RESULT_ID, TASK_ID, null);
snapshot = snapshot(RESULT_ID, TASK_ID);
job = new TaskFileJobEntity();
job.setTaskId(TASK_ID);
job.setResultId(RESULT_ID);
when(fileResultMapper.selectList(any())).thenReturn(rows);
when(fileResultMapper.selectById(11L)).thenReturn(shopIdOldest);
when(fileResultMapper.selectById(21L)).thenReturn(shopNameOldest);
when(fileTaskMapper.selectById(111L)).thenReturn(terminalTask(111L));
when(fileTaskMapper.selectById(121L)).thenReturn(terminalTask(121L));
when(taskDistributedLockService.acquire(MODULE_TYPE, 111L))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
when(taskDistributedLockService.acquire(MODULE_TYPE, 121L))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
when(cacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task);
when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
when(fileResultMapper.updateById(any(FileResultEntity.class))).thenReturn(1);
when(fileResultMapper.selectCount(any())).thenReturn(0L);
service.pruneCompletedHistoryForShop(USER_ID, "shop-id:shop-1");
service.pruneCompletedHistoryForShop(USER_ID, "shop-name:Fallback Shop");
verify(fileResultMapper).deleteById(11L);
verify(fileResultMapper).deleteById(21L);
verify(taskFileJobService).deleteResultJobs(111L, MODULE_TYPE, 11L);
verify(taskFileJobService).deleteResultJobs(121L, MODULE_TYPE, 21L);
verify(taskResultItemService).deleteResultItem(111L, MODULE_TYPE, 11L);
verify(taskResultItemService).deleteResultItem(121L, MODULE_TYPE, 21L);
verify(ossStorageService).deleteObject("result/shop-id-oldest.xlsx");
verify(ossStorageService).deleteObject("result/shop-name-oldest.xlsx");
verify(fileResultMapper, never()).deleteById(1L);
verify(fileResultMapper, never()).deleteById(2L);
verify(fileResultMapper, never()).deleteById(3L);
verify(fileResultMapper, never()).deleteById(4L);
when(taskResultItemService.listResultSnapshots(TASK_ID, MODULE_TYPE, ShopDataCrawlResultItemVo.class))
.thenReturn(List.of(snapshot));
when(excelAssemblyService.countRows(any())).thenReturn(1);
when(dailyFileService.currentBusinessDate()).thenReturn(BUSINESS_DATE);
when(dailyFileService.currentBusinessDateTime()).thenReturn(BUSINESS_TIME);
when(dailyFileService.shopKey(any())).thenReturn("shop-id:shop-1");
when(dailyFileService.shopKeyHash(anyString())).thenReturn("hash-1");
when(dailyFileService.acquireLock(eq(USER_ID), eq("shop-id:shop-1")))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
when(dailyFileService.findMembersByResultId(RESULT_ID)).thenReturn(List.of());
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of());
when(dailyFileService.countObjectReferences(anyString())).thenReturn(0L);
when(dailyFileService.addMember(anyLong(), anyLong(), anyLong())).thenReturn(true);
doAnswer(invocation -> {
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
entity.setId(301L);
return null;
}).when(dailyFileService).insert(any());
}
@Test
void doesNotDeleteOldFileWhileOwningTaskIsStillRunning() {
LocalDateTime now = LocalDateTime.of(2026, 8, 5, 12, 0);
FileResultEntity oldest = result(31L, 131L, USER_ID, "shop-running", "Running Shop",
"result/running-oldest.xlsx", 1, now.minusDays(3));
when(fileResultMapper.selectList(any())).thenReturn(List.of(
result(34L, 134L, USER_ID, "shop-running", "Running Shop", "result/34.xlsx", 1, now),
result(33L, 133L, USER_ID, "shop-running", "Running Shop", "result/33.xlsx", 1, now.minusDays(1)),
result(32L, 132L, USER_ID, "shop-running", "Running Shop", "result/32.xlsx", 1, now.minusDays(2)),
oldest));
when(fileResultMapper.selectById(31L)).thenReturn(oldest);
when(fileTaskMapper.selectById(131L)).thenReturn(task(131L, "RUNNING"));
when(taskDistributedLockService.acquire(MODULE_TYPE, 131L))
void firstSuccessCreatesDailyWorkbookAndMembership() {
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(currentRow));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
service.processResultFileJob(job);
verify(excelAssemblyService).writeWorkbook(any(), eq(List.of(snapshot)));
verify(excelAssemblyService, never()).appendWorkbook(any(), any(), any());
verify(dailyFileService).addMember(301L, TASK_ID, RESULT_ID);
assertEquals("result/new.xlsx", currentRow.getResultFileUrl());
assertEquals(1, currentRow.getRowCount());
ArgumentCaptor<ShopDataCrawlDailyFileEntity> captor = ArgumentCaptor.forClass(ShopDataCrawlDailyFileEntity.class);
verify(dailyFileService).insert(captor.capture());
assertEquals(BUSINESS_DATE, captor.getValue().getBusinessDate());
assertEquals(1L, captor.getValue().getVersion());
}
@Test
void sameDaySuccessAppendsAndReplacesPreviousPointer() {
FileResultEntity previous = result(200L, 100L, "result/old.xlsx");
previous.setUserId(null);
FileTaskEntity previousTask = task();
previousTask.setId(100L);
ShopDataCrawlDailyFileEntity daily = daily("result/old.xlsx", 2);
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(previous, currentRow));
when(fileTaskMapper.selectBatchIds(List.of(100L))).thenReturn(List.of(previousTask));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(false);
when(ossStorageService.readObjectBytes("result/old.xlsx")).thenReturn(new byte[]{1, 2, 3});
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
TransactionSynchronizationManager.initSynchronization();
try {
service.processResultFileJob(job);
verify(excelAssemblyService).appendWorkbook(any(), any(), eq(List.of(snapshot)));
verify(dailyFileService).update(daily);
verify(ossStorageService, never()).deleteObject("result/old.xlsx");
assertNull(previous.getResultFileUrl());
assertEquals("result/new.xlsx", currentRow.getResultFileUrl());
assertEquals(3, currentRow.getRowCount());
List<TransactionSynchronization> synchronizations = TransactionSynchronizationManager.getSynchronizations();
synchronizations.forEach(TransactionSynchronization::afterCommit);
verify(ossStorageService).deleteObject("result/old.xlsx");
synchronizations.forEach(synchronization ->
synchronization.afterCompletion(TransactionSynchronization.STATUS_COMMITTED));
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void repeatedResultDoesNotAppendOrUploadAgain() {
ShopDataCrawlDailyFileEntity daily = daily("result/current.xlsx", 3);
daily.setLatestResultId(RESULT_ID);
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(true);
service.processResultFileJob(job);
verify(excelAssemblyService, never()).writeWorkbook(any(), any());
verify(excelAssemblyService, never()).appendWorkbook(any(), any(), any());
verify(ossStorageService, never()).uploadResultFile(any(), anyString());
assertEquals("result/current.xlsx", currentRow.getResultFileUrl());
assertEquals(3, currentRow.getRowCount());
}
@Test
void sameDaySuccessKeepsLegacyPointerOwnedByAnotherUser() {
FileResultEntity previous = result(200L, 100L, "result/old.xlsx");
previous.setUserId(null);
FileTaskEntity previousTask = task();
previousTask.setId(100L);
previousTask.setUserId(8L);
ShopDataCrawlDailyFileEntity daily = daily("result/old.xlsx", 2);
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(previous, currentRow));
when(fileTaskMapper.selectBatchIds(List.of(100L))).thenReturn(List.of(previousTask));
when(fileResultMapper.selectCount(any())).thenReturn(1L);
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.containsResult(301L, RESULT_ID)).thenReturn(false);
when(ossStorageService.readObjectBytes("result/old.xlsx")).thenReturn(new byte[]{1, 2, 3});
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/new.xlsx");
service.processResultFileJob(job);
assertEquals("result/old.xlsx", previous.getResultFileUrl());
assertEquals("result/new.xlsx", currentRow.getResultFileUrl());
}
@Test
void previousDayRetryDoesNotAppendToCurrentDailyFile() {
currentRow.setResultFileUrl("result/yesterday.xlsx");
ShopDataCrawlDailyFileEntity today = daily("result/today.xlsx", 1);
today.setId(302L);
ShopDataCrawlDailyFileEntity yesterday = daily("result/yesterday.xlsx", 3);
yesterday.setBusinessDate(BUSINESS_DATE.minusDays(1));
yesterday.setLatestResultId(RESULT_ID);
ShopDataCrawlDailyMemberEntity member = member(301L, TASK_ID, RESULT_ID, BUSINESS_TIME.minusDays(1));
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(today);
when(dailyFileService.findMembersByResultId(RESULT_ID)).thenReturn(List.of(member));
when(dailyFileService.findById(301L)).thenReturn(yesterday);
service.processResultFileJob(job);
verify(excelAssemblyService, never()).writeWorkbook(any(), any());
verify(excelAssemblyService, never()).appendWorkbook(any(), any(), any());
verify(ossStorageService, never()).uploadResultFile(any(), anyString());
assertNull(currentRow.getResultFileUrl());
}
@Test
void newDaySuccessDeletesOlderDailyFileAfterNewUpload() {
FileResultEntity previous = result(200L, 100L, "result/yesterday.xlsx");
ShopDataCrawlDailyFileEntity yesterday = daily("result/yesterday.xlsx", 4);
yesterday.setId(300L);
yesterday.setBusinessDate(BUSINESS_DATE.minusDays(1));
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow), List.of(previous, currentRow));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/today.xlsx");
service.processResultFileJob(job);
verify(dailyFileService).deleteDailyFile(300L);
verify(ossStorageService).deleteObject("result/yesterday.xlsx");
assertEquals("result/today.xlsx", currentRow.getResultFileUrl());
assertNull(previous.getResultFileUrl());
}
@Test
void failedNewDayUploadKeepsOlderDailyFile() {
ShopDataCrawlDailyFileEntity yesterday = daily("result/yesterday.xlsx", 4);
yesterday.setId(300L);
yesterday.setBusinessDate(BUSINESS_DATE.minusDays(1));
when(fileResultMapper.selectList(any())).thenReturn(List.of(currentRow));
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(null);
when(dailyFileService.findOlder(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(List.of(yesterday));
doThrow(new IllegalStateException("upload failed"))
.when(ossStorageService).uploadResultFile(any(), eq(MODULE_TYPE));
assertThrows(IllegalStateException.class, () -> service.processResultFileJob(job));
verify(dailyFileService, never()).deleteDailyFile(anyLong());
verify(ossStorageService, never()).deleteObject("result/yesterday.xlsx");
}
@Test
void deletingOneMemberRebuildsDailyWorkbookFromRemainingResults() {
task.setStatus("SUCCESS");
currentRow.setResultFileUrl("result/old.xlsx");
FileResultEntity previous = result(200L, 100L, null);
ShopDataCrawlResultItemVo previousSnapshot = snapshot(200L, 100L);
ShopDataCrawlDailyFileEntity daily = daily("result/old.xlsx", 2);
daily.setLatestTaskId(TASK_ID);
daily.setLatestResultId(RESULT_ID);
ShopDataCrawlDailyMemberEntity previousMember = member(
301L, 100L, 200L, BUSINESS_TIME.minusMinutes(10));
ShopDataCrawlDailyMemberEntity removedMember = member(
301L, TASK_ID, RESULT_ID, BUSINESS_TIME.minusMinutes(5));
when(taskDistributedLockService.acquire(MODULE_TYPE, TASK_ID))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
when(fileResultMapper.selectById(RESULT_ID)).thenReturn(currentRow);
when(fileResultMapper.selectById(200L)).thenReturn(previous);
when(fileResultMapper.selectList(any())).thenReturn(List.of());
when(dailyFileService.findByLatestResultId(RESULT_ID)).thenReturn(List.of(daily));
when(dailyFileService.findMembersByResultId(RESULT_ID)).thenReturn(List.of(removedMember));
when(dailyFileService.findById(301L)).thenReturn(daily);
when(dailyFileService.findForUpdate(USER_ID, "hash-1", BUSINESS_DATE)).thenReturn(daily);
when(dailyFileService.listMembers(301L)).thenReturn(List.of(removedMember, previousMember));
when(taskResultItemService.getResultSnapshot(
100L, MODULE_TYPE, 200L, ShopDataCrawlResultItemVo.class)).thenReturn(previousSnapshot);
when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenReturn("result/rebuilt.xlsx");
service.pruneCompletedHistoryForShop(USER_ID, "shop-id:shop-running");
service.deleteHistory(RESULT_ID, USER_ID);
verify(fileResultMapper, never()).deleteById(31L);
verify(taskFileJobService, never()).deleteResultJobs(131L, MODULE_TYPE, 31L);
verify(ossStorageService, never()).deleteObject("result/running-oldest.xlsx");
verify(excelAssemblyService).writeWorkbook(any(), eq(List.of(previousSnapshot)));
verify(dailyFileService).deleteMembersForResults(Set.of(RESULT_ID));
verify(dailyFileService).update(daily);
verify(ossStorageService).deleteObject("result/old.xlsx");
assertEquals(200L, daily.getLatestResultId());
assertEquals("result/rebuilt.xlsx", daily.getResultFileUrl());
assertEquals("result/rebuilt.xlsx", previous.getResultFileUrl());
assertEquals(1, previous.getRowCount());
}
private FileResultEntity result(Long id, Long taskId, Long userId, String shopId, String shopName,
String resultFileUrl, int success, LocalDateTime createdAt) {
FileResultEntity row = new FileResultEntity();
row.setId(id);
row.setTaskId(taskId);
row.setModuleType(MODULE_TYPE);
row.setUserId(userId);
row.setSourceFileUrl(shopId);
row.setSourceFilename(shopName);
row.setResultFileUrl(resultFileUrl);
row.setSuccess(success);
row.setCreatedAt(createdAt);
return row;
private FileTaskEntity task() {
FileTaskEntity entity = new FileTaskEntity();
entity.setId(TASK_ID);
entity.setModuleType(MODULE_TYPE);
entity.setUserId(USER_ID);
entity.setStatus("RUNNING");
entity.setTaskNo("task-101");
return entity;
}
private FileTaskEntity terminalTask(Long id) {
return task(id, "SUCCESS");
private FileResultEntity result(Long id, Long taskId, String objectKey) {
FileResultEntity entity = new FileResultEntity();
entity.setId(id);
entity.setTaskId(taskId);
entity.setModuleType(MODULE_TYPE);
entity.setUserId(USER_ID);
entity.setSourceFilename("Demo Shop");
entity.setSourceFileUrl("shop-1");
entity.setSuccess(1);
entity.setResultFileUrl(objectKey);
entity.setCreatedAt(BUSINESS_TIME.minusMinutes(5));
return entity;
}
private FileTaskEntity task(Long id, String status) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setModuleType(MODULE_TYPE);
task.setStatus(status);
return task;
private ShopDataCrawlResultItemVo snapshot(Long resultId, Long taskId) {
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
item.setResultId(resultId);
item.setTaskId(taskId);
item.setShopName("Demo Shop");
item.setShopId("shop-1");
item.setSuccess(true);
item.setCountryResults(List.of());
return item;
}
private ShopDataCrawlDailyFileEntity daily(String objectKey, int rowCount) {
ShopDataCrawlDailyFileEntity entity = new ShopDataCrawlDailyFileEntity();
entity.setId(301L);
entity.setUserId(USER_ID);
entity.setShopKey("shop-id:shop-1");
entity.setShopKeyHash("hash-1");
entity.setBusinessDate(BUSINESS_DATE);
entity.setLatestTaskId(100L);
entity.setLatestResultId(200L);
entity.setResultFilename("daily.xlsx");
entity.setResultFileUrl(objectKey);
entity.setResultFileSize(10L);
entity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
entity.setRowCount(rowCount);
entity.setVersion(1L);
return entity;
}
private ShopDataCrawlDailyMemberEntity member(Long dailyFileId,
Long taskId,
Long resultId,
LocalDateTime createdAt) {
ShopDataCrawlDailyMemberEntity entity = new ShopDataCrawlDailyMemberEntity();
entity.setId(resultId);
entity.setDailyFileId(dailyFileId);
entity.setTaskId(taskId);
entity.setResultId(resultId);
entity.setCreatedAt(createdAt);
return entity;
}
}