+116
-3
@@ -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()) {
|
||||
|
||||
+108
@@ -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;
|
||||
}
|
||||
}
|
||||
+250
@@ -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;
|
||||
}
|
||||
}
|
||||
+49
@@ -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();
|
||||
|
||||
+3
-1
@@ -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();
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
+308
-85
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user