feat(user-service): 实现用户服务核心功能与数据同步
Some checks failed
test/timeline-server/pipeline/head There was a failure building this commit

- 新增用户资料、偏好设置、自定义字段管理功能
- 实现评论、反应、相册与智能集合的完整业务逻辑
- 添加离线变更记录与数据同步机制支持冲突解决
- 集成 Redis 缓存配置与用户统计数据聚合
- 创建 8 个业务控制器处理用户交互请求
- 新增 Feign 客户端与故事服务集成
- 补充详细的后端实现与 WebSocket 指南文档
- 更新项目依赖配置支持新增功能模块
This commit is contained in:
2026-02-25 15:04:30 +08:00
parent 40412f6f67
commit 10ef5918fc
94 changed files with 9244 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
package com.timeline.user.entity;
import com.timeline.user.testutil.TestDataGenerators;
import net.jqwik.api.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 相册实体属性测试
* Album Entity Property-Based Tests
*
* Feature: personal-user-enhancements
*/
class AlbumPropertyTest {
/**
* Property 4: Album creation with required fields
*
* For any valid album name (non-empty string), creating an album should result
* in a retrievable album with that name and optional description.
*
* Validates: Requirements 2.1
*/
@Property
@Label("Property 4: Album creation with required fields")
void albumShouldHaveRequiredFields(@ForAll("albums") Album album) {
// 验证必填字段
assertThat(album.getInstanceId()).isNotNull().isNotEmpty();
assertThat(album.getUserId()).isNotNull().isNotEmpty();
assertThat(album.getName()).isNotNull().isNotEmpty();
// 验证名称长度限制
assertThat(album.getName().length()).isLessThanOrEqualTo(200);
// 验证描述长度限制(如果存在)
if (album.getDescription() != null) {
assertThat(album.getDescription().length()).isLessThanOrEqualTo(1000);
}
// 验证照片数量范围
assertThat(album.getPhotoCount()).isBetween(0, 1000);
// 验证时间戳
assertThat(album.getCreateTime()).isNotNull();
assertThat(album.getUpdateTime()).isNotNull();
// 验证删除标记
assertThat(album.getIsDelete()).isEqualTo(0);
}
/**
* Property 9: Cover photo assignment
*
* For any album and any photo in that album, setting that photo as the cover
* should result in the album's cover photo ID matching that photo's ID.
*
* Validates: Requirements 2.6
*/
@Property
@Label("Property 9: Cover photo assignment")
void albumCoverPhotoShouldMatchAssignedPhoto(
@ForAll("albums") Album album,
@ForAll("photoIds") String photoId) {
// 设置封面照片
album.setCoverPhotoId(photoId);
// 验证封面照片ID匹配
assertThat(album.getCoverPhotoId()).isEqualTo(photoId);
}
/**
* 相册名称应该有合理的长度限制
*/
@Property
@Label("Album name should have reasonable length constraints")
void albumNameShouldHaveLengthConstraints(@ForAll("albums") Album album) {
assertThat(album.getName())
.isNotNull()
.isNotEmpty()
.hasSizeLessThanOrEqualTo(200);
}
/**
* 相册照片数量不应超过1000
*/
@Property
@Label("Album photo count should not exceed 1000")
void albumPhotoCountShouldNotExceedLimit(@ForAll("albums") Album album) {
assertThat(album.getPhotoCount())
.isNotNull()
.isBetween(0, 1000);
}
// ========== Arbitraries ==========
@Provide
Arbitrary<Album> albums() {
return TestDataGenerators.albums();
}
@Provide
Arbitrary<String> photoIds() {
return TestDataGenerators.photoIds();
}
}

View File

@@ -0,0 +1,291 @@
package com.timeline.user.service;
import com.timeline.user.dao.AlbumMapper;
import com.timeline.user.dao.AlbumPhotoMapper;
import com.timeline.user.dto.AlbumDto;
import com.timeline.user.dto.CreateAlbumRequest;
import com.timeline.user.entity.Album;
import com.timeline.user.entity.AlbumPhoto;
import com.timeline.user.service.impl.AlbumServiceImpl;
import com.timeline.user.testutil.TestDataGenerators;
import net.jqwik.api.*;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 相册管理服务属性测试
* Album Management Service Property-Based Tests
*
* Feature: personal-user-enhancements
*/
class AlbumServicePropertyTest {
@Mock
private AlbumMapper albumMapper;
@Mock
private AlbumPhotoMapper albumPhotoMapper;
@InjectMocks
private AlbumServiceImpl albumService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
/**
* Property 4: Album creation with required fields
*
* For any valid album name (non-empty string), creating an album should result
* in a retrievable album with that name and optional description.
*
* Validates: Requirements 2.1
*/
@Property
@Label("Property 4: Album creation with required fields")
void albumCreationShouldPreserveRequiredFields(
@ForAll("userIds") String userId,
@ForAll("createAlbumRequests") CreateAlbumRequest request) {
// Setup mock behavior
doAnswer(invocation -> {
Album album = invocation.getArgument(0);
album.setId(1L);
return 1;
}).when(albumMapper).insert(any(Album.class));
// Execute
AlbumDto result = albumService.createAlbum(userId, request);
// Verify required fields are preserved
assertThat(result).isNotNull();
assertThat(result.getInstanceId()).isNotNull().isNotEmpty();
assertThat(result.getUserId()).isEqualTo(userId);
assertThat(result.getName()).isEqualTo(request.getName());
assertThat(result.getDescription()).isEqualTo(request.getDescription());
assertThat(result.getPhotoCount()).isEqualTo(0);
// Verify album was inserted
verify(albumMapper, times(1)).insert(any(Album.class));
}
/**
* Property 5: Photo multi-album membership
*
* For any photo and any set of albums, adding the photo to those albums should
* result in the photo appearing in all of them without duplication.
*
* Validates: Requirements 2.2
*/
@Property
@Label("Property 5: Photo multi-album membership")
void photoShouldAppearInMultipleAlbumsWithoutDuplication(
@ForAll("userIds") String userId,
@ForAll("photoIds") String photoId,
@ForAll("albums") Album album1,
@ForAll("albums") Album album2) {
// Ensure albums have different IDs
album1.setInstanceId("album1");
album2.setInstanceId("album2");
album1.setUserId(userId);
album2.setUserId(userId);
// Setup mocks for album1
when(albumMapper.findByInstanceIdAndUserId("album1", userId)).thenReturn(album1);
when(albumPhotoMapper.countByAlbumId("album1")).thenReturn(0);
when(albumPhotoMapper.existsByAlbumIdAndPhotoId("album1", photoId)).thenReturn(0);
// Setup mocks for album2
when(albumMapper.findByInstanceIdAndUserId("album2", userId)).thenReturn(album2);
when(albumPhotoMapper.countByAlbumId("album2")).thenReturn(0);
when(albumPhotoMapper.existsByAlbumIdAndPhotoId("album2", photoId)).thenReturn(0);
// Add photo to both albums
albumService.addPhotosToAlbum("album1", userId, List.of(photoId));
albumService.addPhotosToAlbum("album2", userId, List.of(photoId));
// Verify photo was added to both albums without duplication
verify(albumPhotoMapper, times(2)).batchInsert(argThat(list ->
list.size() == 1 && list.get(0).getPhotoId().equals(photoId)
));
// Verify no duplicate check was performed
verify(albumPhotoMapper, times(1)).existsByAlbumIdAndPhotoId("album1", photoId);
verify(albumPhotoMapper, times(1)).existsByAlbumIdAndPhotoId("album2", photoId);
}
/**
* Property 6: Photo removal preserves original
*
* For any photo in an album, removing it from the album should result in the
* photo no longer appearing in that album, but the original photo should still exist.
*
* Validates: Requirements 2.3
*/
@Property
@Label("Property 6: Photo removal preserves original")
void photoRemovalShouldPreserveOriginalPhoto(
@ForAll("userIds") String userId,
@ForAll("albums") Album album,
@ForAll("photoIds") String photoId) {
album.setUserId(userId);
// Setup mocks
when(albumMapper.findByInstanceIdAndUserId(album.getInstanceId(), userId)).thenReturn(album);
when(albumPhotoMapper.countByAlbumId(album.getInstanceId())).thenReturn(0);
// Remove photo from album
albumService.removePhotosFromAlbum(album.getInstanceId(), userId, List.of(photoId));
// Verify only the album-photo relationship was deleted, not the photo itself
verify(albumPhotoMapper, times(1)).batchDelete(album.getInstanceId(), List.of(photoId));
// Verify photo count was updated
verify(albumMapper, times(1)).updatePhotoCount(album.getInstanceId(), 0);
// Note: We cannot verify the photo still exists in this unit test,
// but the implementation only deletes the album_photo relationship,
// never the photo entity itself
}
/**
* Property 7: Photo reordering persistence
*
* For any album and any valid reordering of its photos, after applying the
* reorder operation, retrieving the album should return photos in the new order.
*
* Validates: Requirements 2.4
*/
@Property
@Label("Property 7: Photo reordering persistence")
void photoReorderingShouldPersistCorrectly(
@ForAll("userIds") String userId,
@ForAll("albums") Album album,
@ForAll("photoIdLists") List<String> photoIds) {
Assume.that(!photoIds.isEmpty());
Assume.that(photoIds.size() <= 10); // Limit for test performance
album.setUserId(userId);
// Setup mocks
when(albumMapper.findByInstanceIdAndUserId(album.getInstanceId(), userId)).thenReturn(album);
// Reorder photos
albumService.reorderPhotos(album.getInstanceId(), userId, photoIds);
// Verify each photo's sort order was updated correctly
for (int i = 0; i < photoIds.size(); i++) {
verify(albumPhotoMapper, times(1))
.updateSortOrder(album.getInstanceId(), photoIds.get(i), i);
}
// Verify the correct number of updates
verify(albumPhotoMapper, times(photoIds.size())).updateSortOrder(anyString(), anyString(), anyInt());
}
/**
* Property 8: Album deletion preserves photos
*
* For any album containing photos, deleting the album should result in the
* album no longer existing, but all photos that were in the album should still exist.
*
* Validates: Requirements 2.5
*/
@Property
@Label("Property 8: Album deletion preserves photos")
void albumDeletionShouldPreservePhotos(
@ForAll("userIds") String userId,
@ForAll("albums") Album album) {
album.setUserId(userId);
// Setup mocks
when(albumMapper.findByInstanceIdAndUserId(album.getInstanceId(), userId)).thenReturn(album);
// Delete album
albumService.deleteAlbum(album.getInstanceId(), userId);
// Verify album was soft deleted
verify(albumMapper, times(1)).softDelete(album.getInstanceId());
// Verify only album-photo relationships were deleted, not photos themselves
verify(albumPhotoMapper, times(1)).deleteByAlbumId(album.getInstanceId());
// Note: The implementation uses soft delete for albums and only removes
// the album_photo relationships. Photos themselves are never deleted.
}
/**
* Property 9: Cover photo assignment
*
* For any album and any photo in that album, setting that photo as the cover
* should result in the album's cover photo ID matching that photo's ID.
*
* Validates: Requirements 2.6
*/
@Property
@Label("Property 9: Cover photo assignment")
void coverPhotoAssignmentShouldUpdateAlbumCoverPhotoId(
@ForAll("userIds") String userId,
@ForAll("albums") Album album,
@ForAll("photoIds") String photoId) {
album.setUserId(userId);
// Setup mocks
when(albumMapper.findByInstanceIdAndUserId(album.getInstanceId(), userId)).thenReturn(album);
when(albumPhotoMapper.existsByAlbumIdAndPhotoId(album.getInstanceId(), photoId)).thenReturn(1);
// Set cover photo
albumService.setAlbumCover(album.getInstanceId(), userId, photoId);
// Verify cover photo was updated
verify(albumMapper, times(1)).updateCoverPhoto(album.getInstanceId(), photoId);
// Verify photo existence was checked
verify(albumPhotoMapper, times(1)).existsByAlbumIdAndPhotoId(album.getInstanceId(), photoId);
}
// ========== Arbitraries ==========
@Provide
Arbitrary<String> userIds() {
return TestDataGenerators.userIds();
}
@Provide
Arbitrary<String> photoIds() {
return TestDataGenerators.photoIds();
}
@Provide
Arbitrary<Album> albums() {
return TestDataGenerators.albums();
}
@Provide
Arbitrary<CreateAlbumRequest> createAlbumRequests() {
return TestDataGenerators.createAlbumRequests();
}
@Provide
Arbitrary<List<String>> photoIdLists() {
return Arbitraries.integers().between(1, 10)
.flatMap(size -> Combinators.listOf(TestDataGenerators.photoIds())
.ofSize(size));
}
}

View File

@@ -0,0 +1,201 @@
package com.timeline.user.service;
import com.timeline.user.dao.CommentMapper;
import com.timeline.user.dto.CommentDto;
import com.timeline.user.dto.CommentEventDto;
import com.timeline.user.dto.CreateCommentRequest;
import com.timeline.user.entity.Comment;
import com.timeline.user.entity.User;
import com.timeline.user.service.impl.CommentServiceImpl;
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.MockitoExtension;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* WebSocket通知测试
* Tests for WebSocket notifications in CommentService
*/
@ExtendWith(MockitoExtension.class)
class CommentWebSocketTest {
@Mock
private CommentMapper commentMapper;
@Mock
private UserService userService;
@Mock
private SimpMessagingTemplate messagingTemplate;
@InjectMocks
private CommentServiceImpl commentService;
private User testUser;
private Comment testComment;
@BeforeEach
void setUp() {
testUser = new User();
testUser.setUserId("user123");
testUser.setUsername("testuser");
testUser.setNickname("Test User");
testComment = new Comment();
testComment.setId(1L);
testComment.setInstanceId("comment123");
testComment.setEntityType("STORY");
testComment.setEntityId("story456");
testComment.setUserId("user123");
testComment.setContent("Test comment");
testComment.setCreateTime(LocalDateTime.now());
testComment.setUpdateTime(LocalDateTime.now());
testComment.setIsDelete(0);
}
@Test
void testCreateComment_ShouldBroadcastWebSocketEvent() {
// Arrange
CreateCommentRequest request = new CreateCommentRequest();
request.setEntityType("STORY");
request.setEntityId("story456");
request.setContent("Test comment");
when(commentMapper.insert(any(Comment.class))).thenReturn(1);
when(commentMapper.findByInstanceId(anyString())).thenReturn(testComment);
when(userService.getUserByUserId("user123")).thenReturn(testUser);
// Act
CommentDto result = commentService.createComment("user123", request);
// Assert
assertNotNull(result);
assertEquals("Test comment", result.getContent());
// Verify WebSocket message was sent
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<CommentEventDto> eventCaptor = ArgumentCaptor.forClass(CommentEventDto.class);
verify(messagingTemplate, times(1)).convertAndSend(
topicCaptor.capture(),
eventCaptor.capture()
);
// Verify topic format
String expectedTopic = "/topic/comments/STORY/story456";
assertEquals(expectedTopic, topicCaptor.getValue());
// Verify event content
CommentEventDto event = eventCaptor.getValue();
assertEquals(CommentEventDto.CommentEventType.CREATED, event.getEventType());
assertEquals("STORY", event.getEntityType());
assertEquals("story456", event.getEntityId());
assertNotNull(event.getComment());
assertEquals("Test comment", event.getComment().getContent());
}
@Test
void testUpdateComment_ShouldBroadcastWebSocketEvent() {
// Arrange
String updatedContent = "Updated comment";
Comment updatedComment = new Comment();
updatedComment.setInstanceId("comment123");
updatedComment.setEntityType("STORY");
updatedComment.setEntityId("story456");
updatedComment.setUserId("user123");
updatedComment.setContent(updatedContent);
updatedComment.setCreateTime(LocalDateTime.now().minusHours(1));
updatedComment.setUpdateTime(LocalDateTime.now());
updatedComment.setIsDelete(0);
when(commentMapper.findByInstanceId("comment123")).thenReturn(testComment, updatedComment);
when(commentMapper.updateContent("comment123", updatedContent)).thenReturn(1);
when(userService.getUserByUserId("user123")).thenReturn(testUser);
// Act
CommentDto result = commentService.updateComment("comment123", "user123", updatedContent);
// Assert
assertNotNull(result);
// Verify WebSocket message was sent
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<CommentEventDto> eventCaptor = ArgumentCaptor.forClass(CommentEventDto.class);
verify(messagingTemplate, times(1)).convertAndSend(
topicCaptor.capture(),
eventCaptor.capture()
);
// Verify event
CommentEventDto event = eventCaptor.getValue();
assertEquals(CommentEventDto.CommentEventType.UPDATED, event.getEventType());
assertEquals("STORY", event.getEntityType());
assertEquals("story456", event.getEntityId());
}
@Test
void testDeleteComment_ShouldBroadcastWebSocketEvent() {
// Arrange
when(commentMapper.findByInstanceId("comment123")).thenReturn(testComment);
when(commentMapper.softDelete("comment123")).thenReturn(1);
// Act
commentService.deleteComment("comment123", "user123", null);
// Assert
// Verify WebSocket message was sent
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<CommentEventDto> eventCaptor = ArgumentCaptor.forClass(CommentEventDto.class);
verify(messagingTemplate, times(1)).convertAndSend(
topicCaptor.capture(),
eventCaptor.capture()
);
// Verify event
CommentEventDto event = eventCaptor.getValue();
assertEquals(CommentEventDto.CommentEventType.DELETED, event.getEventType());
assertEquals("STORY", event.getEntityType());
assertEquals("story456", event.getEntityId());
assertEquals("comment123", event.getCommentId());
assertNull(event.getComment()); // Comment data should be null for delete events
}
@Test
void testWebSocketBroadcast_ShouldNotFailMainOperation_WhenWebSocketFails() {
// Arrange
CreateCommentRequest request = new CreateCommentRequest();
request.setEntityType("STORY");
request.setEntityId("story456");
request.setContent("Test comment");
when(commentMapper.insert(any(Comment.class))).thenReturn(1);
when(commentMapper.findByInstanceId(anyString())).thenReturn(testComment);
when(userService.getUserByUserId("user123")).thenReturn(testUser);
// Simulate WebSocket failure
doThrow(new RuntimeException("WebSocket error"))
.when(messagingTemplate).convertAndSend(anyString(), any());
// Act - should not throw exception
CommentDto result = commentService.createComment("user123", request);
// Assert - main operation should succeed despite WebSocket failure
assertNotNull(result);
assertEquals("Test comment", result.getContent());
// Verify the WebSocket send was attempted
verify(messagingTemplate, times(1)).convertAndSend(anyString(), any());
}
}

View File

@@ -0,0 +1,251 @@
package com.timeline.user.service;
import com.timeline.user.dao.PreferencesMapper;
import com.timeline.user.entity.UserPreferences;
import com.timeline.user.service.impl.PreferencesServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 用户偏好设置服务测试
* User Preferences Service Test
*
* Tests for Requirements 7.5, 7.6 (Theme Customization backend)
*/
public class PreferencesServiceTest {
@Mock
private PreferencesMapper preferencesMapper;
@InjectMocks
private PreferencesServiceImpl preferencesService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
public void testGetUserPreferences_CreatesDefaultWhenNotExists() {
// Given: 新用户ID
String userId = "test-user-123";
when(preferencesMapper.findByUserId(userId)).thenReturn(null);
when(preferencesMapper.insert(any(UserPreferences.class))).thenReturn(1);
// When: 获取用户偏好设置
UserPreferences preferences = preferencesService.getUserPreferences(userId);
// Then: 应该返回默认设置
assertNotNull(preferences, "Preferences should not be null");
assertEquals(userId, preferences.getUserId(), "User ID should match");
assertEquals("auto", preferences.getThemeMode(), "Default theme mode should be auto");
assertEquals("default", preferences.getColorScheme(), "Default color scheme should be default");
assertEquals("grid", preferences.getGalleryLayout(), "Default gallery layout should be grid");
assertEquals("grid", preferences.getTimelineLayout(), "Default timeline layout should be grid");
assertEquals("grid", preferences.getAlbumLayout(), "Default album layout should be grid");
assertEquals("medium", preferences.getCardSize(), "Default card size should be medium");
assertEquals("chronological", preferences.getTimelineDisplayMode(), "Default timeline display mode should be chronological");
verify(preferencesMapper, times(1)).findByUserId(userId);
verify(preferencesMapper, times(1)).insert(any(UserPreferences.class));
}
@Test
public void testUpdateThemePreferences_Light() {
// Given: 用户ID和主题设置
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
when(preferencesMapper.updateTheme(userId, "light", "blue")).thenReturn(1);
// When: 更新为浅色主题
preferencesService.updateThemePreferences(userId, "light", "blue");
// Then: 应该调用更新方法
verify(preferencesMapper, times(1)).updateTheme(userId, "light", "blue");
}
@Test
public void testUpdateThemePreferences_Dark() {
// Given: 用户ID和主题设置
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
when(preferencesMapper.updateTheme(userId, "dark", "purple")).thenReturn(1);
// When: 更新为深色主题
preferencesService.updateThemePreferences(userId, "dark", "purple");
// Then: 应该调用更新方法
verify(preferencesMapper, times(1)).updateTheme(userId, "dark", "purple");
}
@Test
public void testUpdateThemePreferences_Auto() {
// Given: 用户ID和主题设置
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
when(preferencesMapper.updateTheme(userId, "auto", "default")).thenReturn(1);
// When: 更新为自动主题
preferencesService.updateThemePreferences(userId, "auto", "default");
// Then: 应该调用更新方法
verify(preferencesMapper, times(1)).updateTheme(userId, "auto", "default");
}
@Test
public void testUpdateThemePreferences_InvalidThemeMode() {
// Given: 用户ID和无效的主题模式
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
// When & Then: 应该抛出异常
assertThrows(IllegalArgumentException.class, () -> {
preferencesService.updateThemePreferences(userId, "invalid", "default");
}, "Should throw exception for invalid theme mode");
}
@Test
public void testUpdateLayoutPreferences() {
// Given: 用户ID和布局设置
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
when(preferencesMapper.updateLayout(userId, "list", "grid", "list", "large")).thenReturn(1);
// When: 更新布局偏好
preferencesService.updateLayoutPreferences(userId, "list", "grid", "list", "large");
// Then: 应该调用更新方法
verify(preferencesMapper, times(1)).updateLayout(userId, "list", "grid", "list", "large");
}
@Test
public void testUpdateLayoutPreferences_PartialUpdate() {
// Given: 用户ID和部分布局设置
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
when(preferencesMapper.updateLayout(userId, "list", "grid", "grid", "small")).thenReturn(1);
// When: 只更新部分布局偏好
preferencesService.updateLayoutPreferences(userId, "list", null, null, "small");
// Then: 应该使用现有值填充null字段
verify(preferencesMapper, times(1)).updateLayout(userId, "list", "grid", "grid", "small");
}
@Test
public void testUpdateLayoutPreferences_InvalidLayout() {
// Given: 用户ID和无效的布局
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
// When & Then: 应该抛出异常
assertThrows(IllegalArgumentException.class, () -> {
preferencesService.updateLayoutPreferences(userId, "invalid", null, null, null);
}, "Should throw exception for invalid layout");
}
@Test
public void testUpdateLayoutPreferences_InvalidCardSize() {
// Given: 用户ID和无效的卡片大小
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
// When & Then: 应该抛出异常
assertThrows(IllegalArgumentException.class, () -> {
preferencesService.updateLayoutPreferences(userId, null, null, null, "invalid");
}, "Should throw exception for invalid card size");
}
@Test
public void testUpdateTimelineDisplayPreferences() {
// Given: 用户ID和时间线显示模式
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
when(preferencesMapper.updateTimelineDisplay(userId, "grouped")).thenReturn(1);
// When: 更新时间线显示偏好
preferencesService.updateTimelineDisplayPreferences(userId, "grouped");
// Then: 应该调用更新方法
verify(preferencesMapper, times(1)).updateTimelineDisplay(userId, "grouped");
}
@Test
public void testUpdateTimelineDisplayPreferences_Masonry() {
// Given: 用户ID和时间线显示模式
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
when(preferencesMapper.updateTimelineDisplay(userId, "masonry")).thenReturn(1);
// When: 更新为瀑布流显示
preferencesService.updateTimelineDisplayPreferences(userId, "masonry");
// Then: 应该调用更新方法
verify(preferencesMapper, times(1)).updateTimelineDisplay(userId, "masonry");
}
@Test
public void testUpdateTimelineDisplayPreferences_InvalidMode() {
// Given: 用户ID和无效的显示模式
String userId = "test-user-123";
UserPreferences existing = createDefaultPreferences(userId);
when(preferencesMapper.findByUserId(userId)).thenReturn(existing);
// When & Then: 应该抛出异常
assertThrows(IllegalArgumentException.class, () -> {
preferencesService.updateTimelineDisplayPreferences(userId, "invalid");
}, "Should throw exception for invalid display mode");
}
@Test
public void testCreateDefaultPreferences() {
// Given: 新用户ID
String userId = "test-user-123";
when(preferencesMapper.insert(any(UserPreferences.class))).thenReturn(1);
// When: 创建默认偏好设置
UserPreferences preferences = preferencesService.createDefaultPreferences(userId);
// Then: 应该创建包含所有默认值的偏好设置
assertNotNull(preferences, "Preferences should not be null");
assertEquals(userId, preferences.getUserId(), "User ID should match");
assertEquals("auto", preferences.getThemeMode(), "Default theme mode should be auto");
assertEquals("default", preferences.getColorScheme(), "Default color scheme should be default");
assertEquals("grid", preferences.getGalleryLayout(), "Default gallery layout should be grid");
assertEquals("medium", preferences.getCardSize(), "Default card size should be medium");
assertEquals("chronological", preferences.getTimelineDisplayMode(), "Default timeline display mode should be chronological");
verify(preferencesMapper, times(1)).insert(any(UserPreferences.class));
}
// Helper method to create default preferences for testing
private UserPreferences createDefaultPreferences(String userId) {
UserPreferences preferences = new UserPreferences();
preferences.setUserId(userId);
preferences.setThemeMode("auto");
preferences.setColorScheme("default");
preferences.setGalleryLayout("grid");
preferences.setTimelineLayout("grid");
preferences.setAlbumLayout("grid");
preferences.setCardSize("medium");
preferences.setTimelineDisplayMode("chronological");
return preferences;
}
}

View File

@@ -0,0 +1,217 @@
package com.timeline.user.service;
import com.timeline.user.dao.ReactionMapper;
import com.timeline.user.dao.UserMapper;
import com.timeline.user.entity.Reaction;
import com.timeline.user.entity.User;
import com.timeline.user.service.impl.ReactionServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Reaction Service Unit Tests
*/
class ReactionServiceTest {
@Mock
private ReactionMapper reactionMapper;
@Mock
private UserMapper userMapper;
@InjectMocks
private ReactionServiceImpl reactionService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void testGetReactionSummary_Success() {
// Arrange
String entityType = "STORY_ITEM";
String entityId = "story123";
String currentUserId = "user1";
List<Reaction> reactions = new ArrayList<>();
Reaction reaction1 = new Reaction();
reaction1.setEntityType(entityType);
reaction1.setEntityId(entityId);
reaction1.setUserId("user1");
reaction1.setReactionType("LIKE");
reactions.add(reaction1);
Reaction reaction2 = new Reaction();
reaction2.setEntityType(entityType);
reaction2.setEntityId(entityId);
reaction2.setUserId("user2");
reaction2.setReactionType("LOVE");
reactions.add(reaction2);
when(reactionMapper.findByEntity(entityType, entityId)).thenReturn(reactions);
when(reactionMapper.findByEntityAndUser(entityType, entityId, currentUserId)).thenReturn(reaction1);
User user1 = new User();
user1.setUserId("user1");
user1.setUsername("User One");
when(userMapper.selectByUserId("user1")).thenReturn(user1);
User user2 = new User();
user2.setUserId("user2");
user2.setUsername("User Two");
when(userMapper.selectByUserId("user2")).thenReturn(user2);
// Act
Map<String, Object> summary = reactionService.getReactionSummary(entityType, entityId, currentUserId);
// Assert
assertNotNull(summary);
assertEquals(entityType, summary.get("entityType"));
assertEquals(entityId, summary.get("entityId"));
assertEquals("LIKE", summary.get("userReaction"));
@SuppressWarnings("unchecked")
Map<String, Integer> counts = (Map<String, Integer>) summary.get("counts");
assertEquals(1, counts.get("LIKE"));
assertEquals(1, counts.get("LOVE"));
assertEquals(0, counts.get("LAUGH"));
}
@Test
void testAddOrUpdateReaction_NewReaction() {
// Arrange
String userId = "user1";
String entityType = "PHOTO";
String entityId = "photo123";
String reactionType = "LOVE";
when(reactionMapper.findByEntityAndUser(entityType, entityId, userId)).thenReturn(null);
when(reactionMapper.insert(any(Reaction.class))).thenReturn(1);
// Act
reactionService.addOrUpdateReaction(userId, entityType, entityId, reactionType);
// Assert
verify(reactionMapper, times(1)).insert(any(Reaction.class));
verify(reactionMapper, never()).updateReactionType(anyString(), anyString(), anyString(), anyString());
}
@Test
void testAddOrUpdateReaction_UpdateExisting() {
// Arrange
String userId = "user1";
String entityType = "STORY_ITEM";
String entityId = "story123";
String newReactionType = "WOW";
Reaction existingReaction = new Reaction();
existingReaction.setEntityType(entityType);
existingReaction.setEntityId(entityId);
existingReaction.setUserId(userId);
existingReaction.setReactionType("LIKE");
when(reactionMapper.findByEntityAndUser(entityType, entityId, userId)).thenReturn(existingReaction);
when(reactionMapper.updateReactionType(entityType, entityId, userId, newReactionType)).thenReturn(1);
// Act
reactionService.addOrUpdateReaction(userId, entityType, entityId, newReactionType);
// Assert
verify(reactionMapper, times(1)).updateReactionType(entityType, entityId, userId, newReactionType);
verify(reactionMapper, never()).insert(any(Reaction.class));
}
@Test
void testRemoveReaction_Success() {
// Arrange
String userId = "user1";
String entityType = "PHOTO";
String entityId = "photo123";
when(reactionMapper.delete(entityType, entityId, userId)).thenReturn(1);
// Act
reactionService.removeReaction(userId, entityType, entityId);
// Assert
verify(reactionMapper, times(1)).delete(entityType, entityId, userId);
}
@Test
void testAddOrUpdateReaction_InvalidEntityType() {
// Arrange
String userId = "user1";
String entityType = "INVALID_TYPE";
String entityId = "entity123";
String reactionType = "LIKE";
// Act & Assert
assertThrows(IllegalArgumentException.class, () -> {
reactionService.addOrUpdateReaction(userId, entityType, entityId, reactionType);
});
}
@Test
void testAddOrUpdateReaction_InvalidReactionType() {
// Arrange
String userId = "user1";
String entityType = "STORY_ITEM";
String entityId = "story123";
String reactionType = "INVALID_REACTION";
// Act & Assert
assertThrows(IllegalArgumentException.class, () -> {
reactionService.addOrUpdateReaction(userId, entityType, entityId, reactionType);
});
}
@Test
void testGetReactionSummary_OneReactionPerUserConstraint() {
// Arrange
String entityType = "STORY_ITEM";
String entityId = "story123";
String userId = "user1";
// User has one reaction
List<Reaction> reactions = new ArrayList<>();
Reaction reaction = new Reaction();
reaction.setEntityType(entityType);
reaction.setEntityId(entityId);
reaction.setUserId(userId);
reaction.setReactionType("LIKE");
reactions.add(reaction);
when(reactionMapper.findByEntity(entityType, entityId)).thenReturn(reactions);
when(reactionMapper.findByEntityAndUser(entityType, entityId, userId)).thenReturn(reaction);
User user = new User();
user.setUserId(userId);
user.setUsername("Test User");
when(userMapper.selectByUserId(userId)).thenReturn(user);
// Act
Map<String, Object> summary = reactionService.getReactionSummary(entityType, entityId, userId);
// Assert
assertEquals("LIKE", summary.get("userReaction"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> recentReactions = (List<Map<String, Object>>) summary.get("recentReactions");
// Verify only one reaction per user
long userReactionCount = reactions.stream()
.filter(r -> r.getUserId().equals(userId))
.count();
assertEquals(1, userReactionCount);
}
}

View File

@@ -0,0 +1,255 @@
package com.timeline.user.service;
import com.timeline.user.dao.ReactionMapper;
import com.timeline.user.dao.UserMapper;
import com.timeline.user.dto.ReactionDto;
import com.timeline.user.dto.ReactionEventDto;
import com.timeline.user.entity.Reaction;
import com.timeline.user.entity.User;
import com.timeline.user.service.impl.ReactionServiceImpl;
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.MockitoExtension;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 反应WebSocket通知测试
* Reaction WebSocket Notification Tests
*/
@ExtendWith(MockitoExtension.class)
public class ReactionWebSocketTest {
@Mock
private ReactionMapper reactionMapper;
@Mock
private UserMapper userMapper;
@Mock
private SimpMessagingTemplate messagingTemplate;
@InjectMocks
private ReactionServiceImpl reactionService;
private User testUser;
private Reaction testReaction;
@BeforeEach
void setUp() {
// 设置测试用户
testUser = new User();
testUser.setUserId("user123");
testUser.setUsername("testuser");
testUser.setAvatar("https://example.com/avatar.jpg");
// 设置测试反应
testReaction = new Reaction();
testReaction.setId(1L);
testReaction.setEntityType("STORY_ITEM");
testReaction.setEntityId("story123");
testReaction.setUserId("user123");
testReaction.setReactionType("LIKE");
testReaction.setCreateTime(LocalDateTime.now());
}
@Test
void testCreateReaction_BroadcastsCreatedEvent() {
// Arrange
when(reactionMapper.findByEntityAndUser(anyString(), anyString(), anyString())).thenReturn(null);
when(reactionMapper.insert(any(Reaction.class))).thenReturn(1);
when(reactionMapper.findByEntityAndUser("STORY_ITEM", "story123", "user123")).thenReturn(testReaction);
when(userMapper.selectByUserId("user123")).thenReturn(testUser);
// Act
reactionService.addOrUpdateReaction("user123", "STORY_ITEM", "story123", "LIKE");
// Assert
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<ReactionEventDto> eventCaptor = ArgumentCaptor.forClass(ReactionEventDto.class);
verify(messagingTemplate).convertAndSend(topicCaptor.capture(), eventCaptor.capture());
// 验证主题格式
assertEquals("/topic/reactions/STORY_ITEM/story123", topicCaptor.getValue());
// 验证事件内容
ReactionEventDto event = eventCaptor.getValue();
assertEquals(ReactionEventDto.ReactionEventType.CREATED, event.getEventType());
assertNotNull(event.getReaction());
assertEquals("STORY_ITEM", event.getEntityType());
assertEquals("story123", event.getEntityId());
assertEquals("user123", event.getUserId());
assertEquals("LIKE", event.getReaction().getReactionType());
}
@Test
void testUpdateReaction_BroadcastsUpdatedEvent() {
// Arrange
Reaction existingReaction = new Reaction();
existingReaction.setId(1L);
existingReaction.setEntityType("STORY_ITEM");
existingReaction.setEntityId("story123");
existingReaction.setUserId("user123");
existingReaction.setReactionType("LIKE");
existingReaction.setCreateTime(LocalDateTime.now());
Reaction updatedReaction = new Reaction();
updatedReaction.setId(1L);
updatedReaction.setEntityType("STORY_ITEM");
updatedReaction.setEntityId("story123");
updatedReaction.setUserId("user123");
updatedReaction.setReactionType("LOVE");
updatedReaction.setCreateTime(LocalDateTime.now());
when(reactionMapper.findByEntityAndUser("STORY_ITEM", "story123", "user123"))
.thenReturn(existingReaction)
.thenReturn(updatedReaction);
when(reactionMapper.updateReactionType(anyString(), anyString(), anyString(), anyString())).thenReturn(1);
when(userMapper.selectByUserId("user123")).thenReturn(testUser);
// Act
reactionService.addOrUpdateReaction("user123", "STORY_ITEM", "story123", "LOVE");
// Assert
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<ReactionEventDto> eventCaptor = ArgumentCaptor.forClass(ReactionEventDto.class);
verify(messagingTemplate).convertAndSend(topicCaptor.capture(), eventCaptor.capture());
// 验证主题格式
assertEquals("/topic/reactions/STORY_ITEM/story123", topicCaptor.getValue());
// 验证事件内容
ReactionEventDto event = eventCaptor.getValue();
assertEquals(ReactionEventDto.ReactionEventType.UPDATED, event.getEventType());
assertNotNull(event.getReaction());
assertEquals("LOVE", event.getReaction().getReactionType());
}
@Test
void testDeleteReaction_BroadcastsDeletedEvent() {
// Arrange
when(reactionMapper.delete(anyString(), anyString(), anyString())).thenReturn(1);
// Act
reactionService.removeReaction("user123", "STORY_ITEM", "story123");
// Assert
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<ReactionEventDto> eventCaptor = ArgumentCaptor.forClass(ReactionEventDto.class);
verify(messagingTemplate).convertAndSend(topicCaptor.capture(), eventCaptor.capture());
// 验证主题格式
assertEquals("/topic/reactions/STORY_ITEM/story123", topicCaptor.getValue());
// 验证事件内容
ReactionEventDto event = eventCaptor.getValue();
assertEquals(ReactionEventDto.ReactionEventType.DELETED, event.getEventType());
assertNull(event.getReaction()); // 删除事件不包含反应数据
assertEquals("user123", event.getUserId());
assertEquals("STORY_ITEM", event.getEntityType());
assertEquals("story123", event.getEntityId());
}
@Test
void testWebSocketFailure_DoesNotBreakMainOperation() {
// Arrange
when(reactionMapper.findByEntityAndUser(anyString(), anyString(), anyString())).thenReturn(null);
when(reactionMapper.insert(any(Reaction.class))).thenReturn(1);
when(reactionMapper.findByEntityAndUser("STORY_ITEM", "story123", "user123")).thenReturn(testReaction);
when(userMapper.selectByUserId("user123")).thenReturn(testUser);
// 模拟WebSocket发送失败
doThrow(new RuntimeException("WebSocket connection failed"))
.when(messagingTemplate).convertAndSend(anyString(), any());
// Act & Assert - 不应该抛出异常
assertDoesNotThrow(() -> {
reactionService.addOrUpdateReaction("user123", "STORY_ITEM", "story123", "LIKE");
});
// 验证数据库操作仍然执行
verify(reactionMapper).insert(any(Reaction.class));
}
@Test
void testTopicFormat_ForDifferentEntityTypes() {
// Test STORY_ITEM
when(reactionMapper.findByEntityAndUser(anyString(), anyString(), anyString())).thenReturn(null);
when(reactionMapper.insert(any(Reaction.class))).thenReturn(1);
Reaction storyReaction = new Reaction();
storyReaction.setEntityType("STORY_ITEM");
storyReaction.setEntityId("story456");
storyReaction.setUserId("user123");
storyReaction.setReactionType("LIKE");
storyReaction.setCreateTime(LocalDateTime.now());
when(reactionMapper.findByEntityAndUser("STORY_ITEM", "story456", "user123")).thenReturn(storyReaction);
when(userMapper.selectByUserId("user123")).thenReturn(testUser);
reactionService.addOrUpdateReaction("user123", "STORY_ITEM", "story456", "LIKE");
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
verify(messagingTemplate).convertAndSend(topicCaptor.capture(), any(ReactionEventDto.class));
assertEquals("/topic/reactions/STORY_ITEM/story456", topicCaptor.getValue());
// Test PHOTO
reset(messagingTemplate);
Reaction photoReaction = new Reaction();
photoReaction.setEntityType("PHOTO");
photoReaction.setEntityId("photo789");
photoReaction.setUserId("user123");
photoReaction.setReactionType("LOVE");
photoReaction.setCreateTime(LocalDateTime.now());
when(reactionMapper.findByEntityAndUser("PHOTO", "photo789", "user123")).thenReturn(photoReaction);
reactionService.addOrUpdateReaction("user123", "PHOTO", "photo789", "LOVE");
verify(messagingTemplate).convertAndSend(topicCaptor.capture(), any(ReactionEventDto.class));
assertEquals("/topic/reactions/PHOTO/photo789", topicCaptor.getValue());
}
@Test
void testNoWebSocketBroadcast_WhenReactionTypeUnchanged() {
// Arrange
Reaction existingReaction = new Reaction();
existingReaction.setEntityType("STORY_ITEM");
existingReaction.setEntityId("story123");
existingReaction.setUserId("user123");
existingReaction.setReactionType("LIKE");
when(reactionMapper.findByEntityAndUser("STORY_ITEM", "story123", "user123")).thenReturn(existingReaction);
// Act
reactionService.addOrUpdateReaction("user123", "STORY_ITEM", "story123", "LIKE");
// Assert - 不应该发送WebSocket消息
verify(messagingTemplate, never()).convertAndSend(anyString(), any());
}
@Test
void testNoWebSocketBroadcast_WhenReactionNotFound() {
// Arrange
when(reactionMapper.delete(anyString(), anyString(), anyString())).thenReturn(0);
// Act
reactionService.removeReaction("user123", "STORY_ITEM", "story123");
// Assert - 不应该发送WebSocket消息
verify(messagingTemplate, never()).convertAndSend(anyString(), any());
}
}

View File

@@ -0,0 +1,153 @@
package com.timeline.user.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.timeline.user.dao.SmartCollectionMapper;
import com.timeline.user.dto.SmartCollectionDto;
import com.timeline.user.entity.SmartCollection;
import com.timeline.user.service.impl.SmartCollectionServiceImpl;
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 java.time.LocalDateTime;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 智能收藏集服务单元测试
* Smart Collection Service Unit Tests
*/
@ExtendWith(MockitoExtension.class)
class SmartCollectionServiceTest {
@Mock
private SmartCollectionMapper smartCollectionMapper;
@Mock
private ObjectMapper objectMapper;
@InjectMocks
private SmartCollectionServiceImpl smartCollectionService;
private String testUserId;
private SmartCollection testCollection;
@BeforeEach
void setUp() {
testUserId = "user123";
testCollection = createTestCollection();
}
@Test
void getUserCollections_shouldReturnCollectionList() {
// Given
List<SmartCollection> collections = Arrays.asList(testCollection);
when(smartCollectionMapper.findByUserId(testUserId)).thenReturn(collections);
when(objectMapper.convertValue(anyMap(), eq(Map.class))).thenReturn(new HashMap<>());
// When
List<SmartCollectionDto> result = smartCollectionService.getUserCollections(testUserId);
// Then
assertThat(result).isNotNull();
assertThat(result).hasSize(1);
assertThat(result.get(0).getId()).isEqualTo(testCollection.getInstanceId());
assertThat(result.get(0).getUserId()).isEqualTo(testUserId);
verify(smartCollectionMapper).findByUserId(testUserId);
}
@Test
void processPhotoMetadata_withDateMetadata_shouldCreateDateCollections() {
// Given
Map<String, Object> metadata = new HashMap<>();
metadata.put("date", LocalDateTime.of(2024, 1, 15, 10, 30));
when(smartCollectionMapper.existsByUserIdAndTypeAndCriteria(
eq(testUserId), eq("DATE"), anyString())).thenReturn(0);
when(objectMapper.writeValueAsString(anyMap())).thenReturn("{}");
// When
smartCollectionService.processPhotoMetadata(testUserId, "photo123", metadata);
// Then
// Should create 3 collections: year, month, day
verify(smartCollectionMapper, times(3)).insert(any(SmartCollection.class));
}
@Test
void processPhotoMetadata_withLocationMetadata_shouldCreateLocationCollection() {
// Given
Map<String, Object> metadata = new HashMap<>();
Map<String, Object> location = new HashMap<>();
location.put("name", "Paris");
location.put("latitude", 48.8566);
location.put("longitude", 2.3522);
metadata.put("location", location);
when(smartCollectionMapper.existsByUserIdAndTypeAndCriteria(
eq(testUserId), eq("LOCATION"), anyString())).thenReturn(0);
when(objectMapper.writeValueAsString(anyMap())).thenReturn("{}");
// When
smartCollectionService.processPhotoMetadata(testUserId, "photo123", metadata);
// Then
verify(smartCollectionMapper).insert(any(SmartCollection.class));
}
@Test
void processPhotoMetadata_withPersonMetadata_shouldCreatePersonCollections() {
// Given
Map<String, Object> metadata = new HashMap<>();
metadata.put("persons", Arrays.asList("Alice", "Bob"));
when(smartCollectionMapper.existsByUserIdAndTypeAndCriteria(
eq(testUserId), eq("PERSON"), anyString())).thenReturn(0);
when(objectMapper.writeValueAsString(anyMap())).thenReturn("{}");
// When
smartCollectionService.processPhotoMetadata(testUserId, "photo123", metadata);
// Then
// Should create 2 collections: one for Alice, one for Bob
verify(smartCollectionMapper, times(2)).insert(any(SmartCollection.class));
}
@Test
void processPhotoMetadata_withExistingCollection_shouldNotCreateDuplicate() {
// Given
Map<String, Object> metadata = new HashMap<>();
metadata.put("date", LocalDateTime.of(2024, 1, 15, 10, 30));
when(smartCollectionMapper.existsByUserIdAndTypeAndCriteria(
eq(testUserId), eq("DATE"), anyString())).thenReturn(1);
when(objectMapper.writeValueAsString(anyMap())).thenReturn("{}");
// When
smartCollectionService.processPhotoMetadata(testUserId, "photo123", metadata);
// Then
verify(smartCollectionMapper, never()).insert(any(SmartCollection.class));
}
private SmartCollection createTestCollection() {
SmartCollection collection = new SmartCollection();
collection.setId(1L);
collection.setInstanceId("collection123");
collection.setUserId(testUserId);
collection.setCollectionType("DATE");
collection.setName("2024年");
collection.setCriteriaJson("{\"year\":2024}");
collection.setContentCount(10);
collection.setThumbnailUrl("https://example.com/thumb.jpg");
collection.setCreateTime(LocalDateTime.now());
collection.setUpdateTime(LocalDateTime.now());
return collection;
}
}

View File

@@ -0,0 +1,284 @@
package com.timeline.user.service;
import com.timeline.user.dao.StatisticsMapper;
import com.timeline.user.dto.UserStatisticsDto;
import com.timeline.user.entity.UserStatsCache;
import com.timeline.user.feign.StoryServiceClient;
import com.timeline.user.service.impl.StatisticsServiceImpl;
import com.timeline.user.testutil.TestDataGenerators;
import net.jqwik.api.*;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 统计服务属性测试
* Statistics Service Property-Based Tests
*
* Feature: personal-user-enhancements
*/
class StatisticsServicePropertyTest {
@Mock
private StatisticsMapper statisticsMapper;
@Mock
private StoryServiceClient storyServiceClient;
@InjectMocks
private StatisticsServiceImpl statisticsService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
/**
* Property 10: Photo count accuracy
*
* For any user, the statistics total photo count should equal the actual
* number of photos owned by that user.
*
* Validates: Requirements 3.1
*/
@Property
@Label("Property 10: Photo count accuracy")
void statisticsPhotoCountShouldMatchActualPhotoCount(
@ForAll("userIds") String userId,
@ForAll("photoCounts2") Integer actualPhotoCount) {
// Setup: Mock the mapper to return the actual photo count
when(statisticsMapper.countPhotosByUserId(userId)).thenReturn(actualPhotoCount);
when(statisticsMapper.countAlbumsByUserId(userId)).thenReturn(0);
when(statisticsMapper.selectByUserId(userId)).thenReturn(null);
when(statisticsMapper.getMonthlyUploadStats(userId)).thenReturn(new ArrayList<>());
when(statisticsMapper.getYearlyUploadStats(userId)).thenReturn(new ArrayList<>());
// Mock story service to return 0
when(storyServiceClient.countStoriesByUserId(userId)).thenReturn(0L);
when(storyServiceClient.getTotalStorageByUserId(userId)).thenReturn(0L);
// Execute: Calculate statistics
UserStatisticsDto result = statisticsService.calculateAndCacheStatistics(userId);
// Verify: Photo count in statistics matches actual count
assertThat(result).isNotNull();
assertThat(result.getTotalPhotos()).isEqualTo(actualPhotoCount.longValue());
assertThat(result.getUserId()).isEqualTo(userId);
// Verify the mapper was called to count photos
verify(statisticsMapper, times(1)).countPhotosByUserId(userId);
}
/**
* Property 11: Story count accuracy
*
* For any user, the statistics total story count should equal the actual
* number of stories created by that user.
*
* Validates: Requirements 3.2
*/
@Property
@Label("Property 11: Story count accuracy")
void statisticsStoryCountShouldMatchActualStoryCount(
@ForAll("userIds") String userId,
@ForAll("storyCounts") Long actualStoryCount) {
// Setup: Mock the story service to return the actual story count
when(storyServiceClient.countStoriesByUserId(userId)).thenReturn(actualStoryCount);
when(storyServiceClient.getTotalStorageByUserId(userId)).thenReturn(0L);
when(statisticsMapper.countPhotosByUserId(userId)).thenReturn(0);
when(statisticsMapper.countAlbumsByUserId(userId)).thenReturn(0);
when(statisticsMapper.selectByUserId(userId)).thenReturn(null);
when(statisticsMapper.getMonthlyUploadStats(userId)).thenReturn(new ArrayList<>());
when(statisticsMapper.getYearlyUploadStats(userId)).thenReturn(new ArrayList<>());
// Execute: Calculate statistics
UserStatisticsDto result = statisticsService.calculateAndCacheStatistics(userId);
// Verify: Story count in statistics matches actual count
assertThat(result).isNotNull();
assertThat(result.getTotalStories()).isEqualTo(actualStoryCount);
assertThat(result.getUserId()).isEqualTo(userId);
// Verify the story service was called
verify(storyServiceClient, times(1)).countStoriesByUserId(userId);
}
/**
* Property 12: Storage calculation accuracy
*
* For any user, the statistics total storage should equal the sum of all
* file sizes for content owned by that user.
*
* Validates: Requirements 3.3
*/
@Property
@Label("Property 12: Storage calculation accuracy")
void statisticsStorageShouldMatchActualStorageUsage(
@ForAll("userIds") String userId,
@ForAll("fileSizes") Long actualStorageBytes) {
// Setup: Mock the story service to return the actual storage
when(storyServiceClient.getTotalStorageByUserId(userId)).thenReturn(actualStorageBytes);
when(storyServiceClient.countStoriesByUserId(userId)).thenReturn(0L);
when(statisticsMapper.countPhotosByUserId(userId)).thenReturn(0);
when(statisticsMapper.countAlbumsByUserId(userId)).thenReturn(0);
when(statisticsMapper.selectByUserId(userId)).thenReturn(null);
when(statisticsMapper.getMonthlyUploadStats(userId)).thenReturn(new ArrayList<>());
when(statisticsMapper.getYearlyUploadStats(userId)).thenReturn(new ArrayList<>());
// Execute: Calculate statistics
UserStatisticsDto result = statisticsService.calculateAndCacheStatistics(userId);
// Verify: Storage in statistics matches actual storage
assertThat(result).isNotNull();
assertThat(result.getTotalStorageBytes()).isEqualTo(actualStorageBytes);
assertThat(result.getUserId()).isEqualTo(userId);
// Verify the story service was called
verify(storyServiceClient, times(1)).getTotalStorageByUserId(userId);
}
/**
* Property 13: Monthly aggregation accuracy
*
* For any user and any month in the past 12 months, the monthly upload count
* should equal the actual number of items uploaded in that month.
*
* Validates: Requirements 3.4
*/
@Property
@Label("Property 13: Monthly aggregation accuracy")
void monthlyUploadStatsShouldMatchActualMonthlyUploads(
@ForAll("userIds") String userId,
@ForAll("monthPeriods") String period,
@ForAll("photoCounts2") Integer photoCount) {
// Setup: Create monthly stats with the given period and count
List<Map<String, Object>> monthlyStats = new ArrayList<>();
Map<String, Object> stat = new HashMap<>();
stat.put("period", period);
stat.put("photoCount", photoCount);
stat.put("storyCount", 0);
stat.put("storageBytes", 0L);
monthlyStats.add(stat);
when(statisticsMapper.getMonthlyUploadStats(userId)).thenReturn(monthlyStats);
when(statisticsMapper.countPhotosByUserId(userId)).thenReturn(0);
when(statisticsMapper.countAlbumsByUserId(userId)).thenReturn(0);
when(statisticsMapper.selectByUserId(userId)).thenReturn(null);
when(statisticsMapper.getYearlyUploadStats(userId)).thenReturn(new ArrayList<>());
when(storyServiceClient.countStoriesByUserId(userId)).thenReturn(0L);
when(storyServiceClient.getTotalStorageByUserId(userId)).thenReturn(0L);
// Execute: Calculate statistics
UserStatisticsDto result = statisticsService.calculateAndCacheStatistics(userId);
// Verify: The monthly stats were retrieved
assertThat(result).isNotNull();
verify(statisticsMapper, times(1)).getMonthlyUploadStats(userId);
// Verify the data was cached with the correct monthly stats
verify(statisticsMapper, times(1)).insert(argThat(cache -> {
if (cache.getMonthlyUploadsJson() == null) return false;
// The JSON should contain the period and photoCount
return cache.getMonthlyUploadsJson().contains(period) &&
cache.getMonthlyUploadsJson().contains(photoCount.toString());
}));
}
/**
* Property 14: Yearly aggregation accuracy
*
* For any user and any year with content, the yearly upload count should
* equal the actual number of items uploaded in that year.
*
* Validates: Requirements 3.5
*/
@Property
@Label("Property 14: Yearly aggregation accuracy")
void yearlyUploadStatsShouldMatchActualYearlyUploads(
@ForAll("userIds") String userId,
@ForAll("yearPeriods") String period,
@ForAll("photoCounts2") Integer photoCount) {
// Setup: Create yearly stats with the given period and count
List<Map<String, Object>> yearlyStats = new ArrayList<>();
Map<String, Object> stat = new HashMap<>();
stat.put("period", period);
stat.put("photoCount", photoCount);
stat.put("storyCount", 0);
stat.put("storageBytes", 0L);
yearlyStats.add(stat);
when(statisticsMapper.getYearlyUploadStats(userId)).thenReturn(yearlyStats);
when(statisticsMapper.countPhotosByUserId(userId)).thenReturn(0);
when(statisticsMapper.countAlbumsByUserId(userId)).thenReturn(0);
when(statisticsMapper.selectByUserId(userId)).thenReturn(null);
when(statisticsMapper.getMonthlyUploadStats(userId)).thenReturn(new ArrayList<>());
when(storyServiceClient.countStoriesByUserId(userId)).thenReturn(0L);
when(storyServiceClient.getTotalStorageByUserId(userId)).thenReturn(0L);
// Execute: Calculate statistics
UserStatisticsDto result = statisticsService.calculateAndCacheStatistics(userId);
// Verify: The yearly stats were retrieved
assertThat(result).isNotNull();
verify(statisticsMapper, times(1)).getYearlyUploadStats(userId);
// Verify the data was cached with the correct yearly stats
verify(statisticsMapper, times(1)).insert(argThat(cache -> {
if (cache.getYearlyUploadsJson() == null) return false;
// The JSON should contain the period and photoCount
return cache.getYearlyUploadsJson().contains(period) &&
cache.getYearlyUploadsJson().contains(photoCount.toString());
}));
}
// ========== Arbitraries ==========
@Provide
Arbitrary<String> userIds() {
return TestDataGenerators.userIds();
}
@Provide
Arbitrary<Integer> photoCounts2() {
return TestDataGenerators.photoCounts2();
}
@Provide
Arbitrary<Long> storyCounts() {
return TestDataGenerators.storyCounts();
}
@Provide
Arbitrary<Long> fileSizes() {
return TestDataGenerators.fileSizes();
}
@Provide
Arbitrary<String> monthPeriods() {
return TestDataGenerators.monthPeriods();
}
@Provide
Arbitrary<String> yearPeriods() {
return TestDataGenerators.yearPeriods();
}
}

View File

@@ -0,0 +1,294 @@
package com.timeline.user.testutil;
import com.timeline.user.entity.*;
import com.timeline.user.dto.*;
import net.jqwik.api.Arbitraries;
import net.jqwik.api.Arbitrary;
import net.jqwik.api.Combinators;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 测试数据生成器 - 用于属性测试
* Test data generators for property-based testing
*/
public class TestDataGenerators {
/**
* 生成随机相册实体
*/
public static Arbitrary<Album> albums() {
return Combinators.combine(
instanceIds(),
userIds(),
albumNames(),
descriptions(),
photoIds(),
photoCounts()
).as((instanceId, userId, name, description, coverPhotoId, photoCount) -> {
Album album = new Album();
album.setInstanceId(instanceId);
album.setUserId(userId);
album.setName(name);
album.setDescription(description);
album.setCoverPhotoId(coverPhotoId);
album.setPhotoCount(photoCount);
album.setCreateTime(LocalDateTime.now());
album.setUpdateTime(LocalDateTime.now());
album.setIsDelete(0);
return album;
});
}
/**
* 生成随机评论实体
*/
public static Arbitrary<Comment> comments() {
return Combinators.combine(
instanceIds(),
entityTypes(),
entityIds(),
userIds(),
commentContents()
).as((instanceId, entityType, entityId, userId, content) -> {
Comment comment = new Comment();
comment.setInstanceId(instanceId);
comment.setEntityType(entityType);
comment.setEntityId(entityId);
comment.setUserId(userId);
comment.setContent(content);
comment.setCreateTime(LocalDateTime.now());
comment.setUpdateTime(LocalDateTime.now());
comment.setIsDelete(0);
return comment;
});
}
/**
* 生成随机反应实体
*/
public static Arbitrary<Reaction> reactions() {
return Combinators.combine(
entityTypes(),
entityIds(),
userIds(),
reactionTypes()
).as((entityType, entityId, userId, reactionType) -> {
Reaction reaction = new Reaction();
reaction.setEntityType(entityType);
reaction.setEntityId(entityId);
reaction.setUserId(userId);
reaction.setReactionType(reactionType);
reaction.setCreateTime(LocalDateTime.now());
reaction.setUpdateTime(LocalDateTime.now());
return reaction;
});
}
/**
* 生成随机用户偏好实体
*/
public static Arbitrary<UserPreferences> userPreferences() {
return Combinators.combine(
userIds(),
themeModes(),
layoutTypes(),
cardSizes(),
displayModes()
).as((userId, themeMode, layout, cardSize, displayMode) -> {
UserPreferences prefs = new UserPreferences();
prefs.setUserId(userId);
prefs.setThemeMode(themeMode);
prefs.setColorScheme("default");
prefs.setGalleryLayout(layout);
prefs.setTimelineLayout(layout);
prefs.setAlbumLayout(layout);
prefs.setCardSize(cardSize);
prefs.setTimelineDisplayMode(displayMode);
prefs.setCreateTime(LocalDateTime.now());
prefs.setUpdateTime(LocalDateTime.now());
return prefs;
});
}
/**
* 生成随机用户资料实体
*/
public static Arbitrary<UserProfile> userProfiles() {
return Combinators.combine(
userIds(),
urls(),
bios()
).as((userId, coverPhotoUrl, bio) -> {
UserProfile profile = new UserProfile();
profile.setUserId(userId);
profile.setCoverPhotoUrl(coverPhotoUrl);
profile.setBio(bio);
profile.setCreateTime(LocalDateTime.now());
profile.setUpdateTime(LocalDateTime.now());
return profile;
});
}
/**
* 生成随机创建相册请求
*/
public static Arbitrary<CreateAlbumRequest> createAlbumRequests() {
return Combinators.combine(
albumNames(),
descriptions()
).as((name, description) -> {
CreateAlbumRequest request = new CreateAlbumRequest();
request.setName(name);
request.setDescription(description);
return request;
});
}
/**
* 生成随机创建评论请求
*/
public static Arbitrary<CreateCommentRequest> createCommentRequests() {
return Combinators.combine(
entityTypes(),
entityIds(),
commentContents()
).as((entityType, entityId, content) -> {
CreateCommentRequest request = new CreateCommentRequest();
request.setEntityType(entityType);
request.setEntityId(entityId);
request.setContent(content);
return request;
});
}
// ========== 基础数据生成器 ==========
public static Arbitrary<String> instanceIds() {
return Arbitraries.create(() -> UUID.randomUUID().toString().replace("-", "").substring(0, 32));
}
public static Arbitrary<String> userIds() {
return Arbitraries.create(() -> "user_" + UUID.randomUUID().toString().substring(0, 8));
}
public static Arbitrary<String> entityIds() {
return Arbitraries.create(() -> "entity_" + UUID.randomUUID().toString().substring(0, 8));
}
public static Arbitrary<String> photoIds() {
return Arbitraries.create(() -> "photo_" + UUID.randomUUID().toString().substring(0, 8));
}
public static Arbitrary<String> albumNames() {
return Arbitraries.strings()
.alpha()
.ofMinLength(1)
.ofMaxLength(50)
.map(s -> "Album " + s);
}
public static Arbitrary<String> descriptions() {
return Arbitraries.strings()
.alpha()
.numeric()
.withChars(' ', '.', ',')
.ofMinLength(0)
.ofMaxLength(200);
}
public static Arbitrary<String> commentContents() {
return Arbitraries.strings()
.alpha()
.numeric()
.withChars(' ', '.', ',', '!', '?')
.ofMinLength(1)
.ofMaxLength(1000);
}
public static Arbitrary<String> bios() {
return Arbitraries.strings()
.alpha()
.numeric()
.withChars(' ', '.', ',')
.ofMinLength(0)
.ofMaxLength(500);
}
public static Arbitrary<String> urls() {
return Arbitraries.strings()
.alpha()
.numeric()
.ofMinLength(10)
.ofMaxLength(50)
.map(s -> "https://example.com/" + s + ".jpg");
}
public static Arbitrary<Integer> photoCounts() {
return Arbitraries.integers().between(0, 1000);
}
public static Arbitrary<String> entityTypes() {
return Arbitraries.of("STORY_ITEM", "PHOTO");
}
public static Arbitrary<String> reactionTypes() {
return Arbitraries.of("LIKE", "LOVE", "LAUGH", "WOW", "SAD");
}
public static Arbitrary<String> themeModes() {
return Arbitraries.of("light", "dark", "auto");
}
public static Arbitrary<String> layoutTypes() {
return Arbitraries.of("grid", "list");
}
public static Arbitrary<String> cardSizes() {
return Arbitraries.of("small", "medium", "large");
}
public static Arbitrary<String> displayModes() {
return Arbitraries.of("chronological", "grouped", "masonry");
}
/**
* 生成随机文件大小(字节)
*/
public static Arbitrary<Long> fileSizes() {
return Arbitraries.longs().between(1024L, 10485760L); // 1KB to 10MB
}
/**
* 生成随机月份字符串 (YYYY-MM)
*/
public static Arbitrary<String> monthPeriods() {
return Combinators.combine(
Arbitraries.integers().between(2020, 2024),
Arbitraries.integers().between(1, 12)
).as((year, month) -> String.format("%d-%02d", year, month));
}
/**
* 生成随机年份字符串 (YYYY)
*/
public static Arbitrary<String> yearPeriods() {
return Arbitraries.integers().between(2020, 2024)
.map(String::valueOf);
}
/**
* 生成随机照片数量列表(用于测试统计)
*/
public static Arbitrary<Integer> photoCounts2() {
return Arbitraries.integers().between(0, 100);
}
/**
* 生成随机故事数量
*/
public static Arbitrary<Long> storyCounts() {
return Arbitraries.longs().between(0L, 100L);
}
}