feat(user-service): 实现用户服务核心功能与数据同步
Some checks failed
test/timeline-server/pipeline/head There was a failure building this commit
Some checks failed
test/timeline-server/pipeline/head There was a failure building this commit
- 新增用户资料、偏好设置、自定义字段管理功能 - 实现评论、反应、相册与智能集合的完整业务逻辑 - 添加离线变更记录与数据同步机制支持冲突解决 - 集成 Redis 缓存配置与用户统计数据聚合 - 创建 8 个业务控制器处理用户交互请求 - 新增 Feign 客户端与故事服务集成 - 补充详细的后端实现与 WebSocket 指南文档 - 更新项目依赖配置支持新增功能模块
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user