request = $this->createMock(IRequest::class); $this->postMapper = $this->createMock(PostMapper::class); $this->threadMapper = $this->createMock(ThreadMapper::class); $this->categoryMapper = $this->createMock(CategoryMapper::class); $this->forumUserMapper = $this->createMock(ForumUserMapper::class); $this->reactionMapper = $this->createMock(ReactionMapper::class); $this->bbCodeService = $this->createMock(BBCodeService::class); $this->bbCodeMapper = $this->createMock(BBCodeMapper::class); $this->permissionService = $this->createMock(PermissionService::class); $this->readMarkerMapper = $this->createMock(ReadMarkerMapper::class); $this->notificationService = $this->createMock(NotificationService::class); $this->postEnrichmentService = $this->createMock(PostEnrichmentService::class); $this->postHistoryService = $this->createMock(PostHistoryService::class); $this->userService = $this->createMock(UserService::class); $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); // Mock post enrichment service to return serialized post with mock data $this->postEnrichmentService->method('enrichPost') ->willReturnCallback(function ($post) { $data = is_array($post) ? $post : $post->jsonSerialize(); $data['author'] = ['userId' => $data['authorId'], 'displayName' => 'Test User']; $data['reactions'] = []; return $data; }); $this->controller = new PostController( Application::APP_ID, $this->request, $this->postMapper, $this->threadMapper, $this->categoryMapper, $this->forumUserMapper, $this->reactionMapper, $this->bbCodeService, $this->bbCodeMapper, $this->permissionService, $this->readMarkerMapper, $this->notificationService, $this->postEnrichmentService, $this->postHistoryService, $this->userService, $this->userSession, $this->logger ); } public function testByThreadReturnsPostsSuccessfully(): void { $threadId = 1; $limit = 50; $offset = 0; // Create mock posts $post1 = $this->createMockPost(1, $threadId, 'user1', 'Test content 1'); $post2 = $this->createMockPost(2, $threadId, 'user2', 'Test content 2'); $posts = [$post1, $post2]; // Create mock BBCode $bbcode = new BBCode(); $bbcode->setId(1); $bbcode->setTag('b'); $bbcode->setReplacement('{content}'); $bbcode->setEnabled(true); // Create mock reactions $reaction1 = $this->createMockReaction(1, 1, 'user1', '👍'); $reaction2 = $this->createMockReaction(2, 1, 'user2', '👍'); // Mock user session $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); $this->userSession->method('getUser')->willReturn($user); // Set up expectations $this->postMapper->expects($this->once()) ->method('findByThreadId') ->with($threadId, $limit, $offset) ->willReturn($posts); $this->bbCodeMapper->expects($this->once()) ->method('findAllEnabled') ->willReturn([$bbcode]); $this->reactionMapper->expects($this->once()) ->method('findByPostIds') ->with([1, 2]) ->willReturn([$reaction1, $reaction2]); // Mock userService to return enriched user data $this->userService->expects($this->once()) ->method('enrichMultipleUsers') ->with(['user1', 'user2']) ->willReturn([ 'user1' => ['userId' => 'user1', 'displayName' => 'User 1', 'roles' => []], 'user2' => ['userId' => 'user2', 'displayName' => 'User 2', 'roles' => []], ]); // Execute $response = $this->controller->byThread($threadId, $limit, $offset); // Assert $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertIsArray($data); $this->assertCount(2, $data); $this->assertEquals(1, $data[0]['id']); $this->assertEquals(2, $data[1]['id']); $this->assertArrayHasKey('reactions', $data[0]); $this->assertArrayHasKey('reactions', $data[1]); } public function testByThreadHandlesEmptyPosts(): void { $threadId = 1; $this->postMapper->expects($this->once()) ->method('findByThreadId') ->willReturn([]); $this->bbCodeMapper->expects($this->once()) ->method('findAllEnabled') ->willReturn([]); $this->reactionMapper->expects($this->once()) ->method('findByPostIds') ->with([]) ->willReturn([]); $response = $this->controller->byThread($threadId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $this->assertIsArray($response->getData()); $this->assertCount(0, $response->getData()); } public function testShowReturnsPostSuccessfully(): void { $postId = 1; $post = $this->createMockPost($postId, 1, 'user1', 'Test content'); $this->postMapper->expects($this->once()) ->method('find') ->with($postId) ->willReturn($post); $response = $this->controller->show($postId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals($postId, $data['id']); $this->assertEquals('user1', $data['authorId']); } public function testShowReturnsNotFoundWhenPostDoesNotExist(): void { $postId = 999; $this->postMapper->expects($this->once()) ->method('find') ->with($postId) ->willThrowException(new DoesNotExistException('Post not found')); $response = $this->controller->show($postId); $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); $this->assertEquals(['error' => 'Post not found'], $response->getData()); } public function testCreatePostSuccessfully(): void { $threadId = 1; $content = 'New post content'; $userId = 'user1'; // Mock user $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); // Mock forum user $forumUser = new ForumUser(); $forumUser->setId(1); $forumUser->setUserId($userId); $forumUser->setPostCount(0); $forumUser->setCreatedAt(time()); $forumUser->setUpdatedAt(time()); // Mock thread $thread = new Thread(); $thread->setId($threadId); $thread->setCategoryId(1); $thread->setPostCount(1); $thread->setCreatedAt(time()); $thread->setUpdatedAt(time()); // Mock category $category = new Category(); $category->setId(1); $category->setPostCount(1); // Mock created post $createdPost = $this->createMockPost(1, $threadId, $userId, $content); $this->postMapper->expects($this->once()) ->method('insert') ->willReturnCallback(function ($post) use ($createdPost) { return $createdPost; }); // Mock readMarkerMapper $this->readMarkerMapper->expects($this->once()) ->method('createOrUpdate') ->with($userId, $threadId, 1); // Mock thread update $this->threadMapper->expects($this->once()) ->method('find') ->with($threadId) ->willReturn($thread); $this->threadMapper->expects($this->once()) ->method('update') ->willReturn($thread); // Mock category update $this->categoryMapper->expects($this->once()) ->method('find') ->willReturn($category); $this->categoryMapper->expects($this->once()) ->method('update') ->willReturn($category); // Mock forum user increment (void methods, no return value expectations) $this->forumUserMapper->expects($this->once()) ->method('incrementPostCount') ->with($userId); // Mock notification service $this->notificationService->expects($this->once()) ->method('notifyThreadSubscribers') ->with($threadId, 1, $userId); $response = $this->controller->create($threadId, $content); $this->assertEquals(Http::STATUS_CREATED, $response->getStatus()); $data = $response->getData(); $this->assertEquals(1, $data['id']); $this->assertEquals($threadId, $data['threadId']); $this->assertEquals($userId, $data['authorId']); } public function testCreatePostReturnsUnauthorizedWhenUserNotAuthenticated(): void { $threadId = 1; $content = 'New post content'; $this->userSession->method('getUser')->willReturn(null); $response = $this->controller->create($threadId, $content); $this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus()); $this->assertEquals(['error' => 'User not authenticated'], $response->getData()); } public function testUpdatePostSuccessfullyAsAuthor(): void { $postId = 1; $userId = 'user1'; $newContent = 'Updated content'; $post = $this->createMockPost($postId, 1, $userId, 'Original content'); // Mock user (author) $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); // Mock permission service $this->permissionService->expects($this->once()) ->method('getCategoryIdFromPost') ->with($postId) ->willReturn(1); $this->permissionService->expects($this->once()) ->method('hasCategoryPermission') ->with($userId, 1, 'canModerate') ->willReturn(false); // User is not a moderator, but is the author $this->postMapper->expects($this->once()) ->method('find') ->with($postId) ->willReturn($post); $this->postMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedPost) use ($newContent) { $this->assertEquals($newContent, $updatedPost->getContent()); $this->assertTrue($updatedPost->getIsEdited()); $this->assertNotNull($updatedPost->getEditedAt()); return $updatedPost; }); $response = $this->controller->update($postId, $newContent); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals($postId, $data['id']); } public function testUpdatePostSuccessfullyAsModerator(): void { $postId = 1; $userId = 'moderator1'; $postAuthorId = 'user1'; $newContent = 'Updated content'; $post = $this->createMockPost($postId, 1, $postAuthorId, 'Original content'); // Mock user (moderator, not author) $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); // Mock permission service $this->permissionService->expects($this->once()) ->method('getCategoryIdFromPost') ->with($postId) ->willReturn(1); $this->permissionService->expects($this->once()) ->method('hasCategoryPermission') ->with($userId, 1, 'canModerate') ->willReturn(true); // User is a moderator $this->postMapper->expects($this->once()) ->method('find') ->with($postId) ->willReturn($post); $this->postMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedPost) use ($newContent) { $this->assertEquals($newContent, $updatedPost->getContent()); $this->assertTrue($updatedPost->getIsEdited()); $this->assertNotNull($updatedPost->getEditedAt()); return $updatedPost; }); $response = $this->controller->update($postId, $newContent); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals($postId, $data['id']); } public function testUpdatePostReturnsForbiddenWhenNotAuthorOrModerator(): void { $postId = 1; $userId = 'user2'; $postAuthorId = 'user1'; $newContent = 'Updated content'; $post = $this->createMockPost($postId, 1, $postAuthorId, 'Original content'); // Mock user (not author, not moderator) $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); // Mock permission service $this->permissionService->expects($this->once()) ->method('getCategoryIdFromPost') ->with($postId) ->willReturn(1); $this->permissionService->expects($this->once()) ->method('hasCategoryPermission') ->with($userId, 1, 'canModerate') ->willReturn(false); // User is neither author nor moderator $this->postMapper->expects($this->once()) ->method('find') ->with($postId) ->willReturn($post); $response = $this->controller->update($postId, $newContent); $this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus()); $this->assertEquals(['error' => 'Insufficient permissions to edit this post'], $response->getData()); } public function testUpdatePostReturnsUnauthorizedWhenUserNotAuthenticated(): void { $postId = 1; $newContent = 'Updated content'; $this->userSession->method('getUser')->willReturn(null); $response = $this->controller->update($postId, $newContent); $this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus()); $this->assertEquals(['error' => 'User not authenticated'], $response->getData()); } public function testUpdatePostReturnsNotFoundWhenPostDoesNotExist(): void { $postId = 999; $userId = 'user1'; $newContent = 'Updated content'; // Mock user $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); $this->postMapper->expects($this->once()) ->method('find') ->with($postId) ->willThrowException(new DoesNotExistException('Post not found')); $response = $this->controller->update($postId, $newContent); $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); $this->assertEquals(['error' => 'Post not found'], $response->getData()); } public function testDestroyPostSuccessfully(): void { $postId = 1; $userId = 'user1'; $post = $this->createMockPost($postId, 1, $userId, 'Test content'); // Mock user session $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); // Mock permission service $this->permissionService->expects($this->once()) ->method('getCategoryIdFromPost') ->with($postId) ->willReturn(1); $this->permissionService->expects($this->once()) ->method('hasCategoryPermission') ->with($userId, 1, 'canModerate') ->willReturn(false); // User is not moderator but is author $this->postMapper->expects($this->once()) ->method('find') ->with($postId) ->willReturn($post); $this->postMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedPost) { $this->assertNotNull($updatedPost->getDeletedAt()); return $updatedPost; }); $response = $this->controller->destroy($postId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $this->assertEquals(['success' => true], $response->getData()); } public function testDestroyPostReturnsNotFoundWhenPostDoesNotExist(): void { $postId = 999; $userId = 'user1'; // Mock user session $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); $this->postMapper->expects($this->once()) ->method('find') ->with($postId) ->willThrowException(new DoesNotExistException('Post not found')); $response = $this->controller->destroy($postId); $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); $this->assertEquals(['error' => 'Post not found'], $response->getData()); } private function createMockPost(int $id, int $threadId, string $authorId, string $content): Post { $post = new Post(); $post->setId($id); $post->setThreadId($threadId); $post->setAuthorId($authorId); $post->setContent($content); $post->setIsEdited(false); $post->setIsFirstPost(false); $post->setCreatedAt(time()); $post->setUpdatedAt(time()); return $post; } private function createMockReaction(int $id, int $postId, string $userId, string $reactionType): Reaction { $reaction = new Reaction(); $reaction->setId($id); $reaction->setPostId($postId); $reaction->setUserId($userId); $reaction->setReactionType($reactionType); $reaction->setCreatedAt(time()); return $reaction; } public function testCreatePostIncrementsThreadPostCount(): void { $threadId = 1; $content = 'New reply post content'; $userId = 'user1'; $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); $thread = new Thread(); $thread->setId($threadId); $thread->setCategoryId(1); $thread->setPostCount(5); // Thread has 5 replies $category = new Category(); $category->setId(1); $category->setPostCount(10); // Category has 10 total replies $createdPost = $this->createMockPost(1, $threadId, $userId, $content); $this->postMapper->expects($this->once()) ->method('insert') ->willReturn($createdPost); $this->readMarkerMapper->method('createOrUpdate'); $this->threadMapper->expects($this->once()) ->method('find') ->willReturn($thread); $this->threadMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedThread) { // Thread post count should be incremented from 5 to 6 $this->assertEquals(6, $updatedThread->getPostCount()); return $updatedThread; }); $this->categoryMapper->expects($this->once()) ->method('find') ->willReturn($category); $this->categoryMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedCategory) { // Category post count should be incremented from 10 to 11 $this->assertEquals(11, $updatedCategory->getPostCount()); return $updatedCategory; }); $this->forumUserMapper->expects($this->once()) ->method('incrementPostCount') ->with($userId); $this->notificationService->method('notifyThreadSubscribers'); $response = $this->controller->create($threadId, $content); $this->assertEquals(Http::STATUS_CREATED, $response->getStatus()); } public function testDestroyPostDecrementsThreadPostCount(): void { $postId = 1; $userId = 'user1'; $post = $this->createMockPost($postId, 1, $userId, 'Test content'); $post->setIsFirstPost(false); // Regular reply post $thread = new Thread(); $thread->setId(1); $thread->setCategoryId(1); $thread->setPostCount(5); // Thread has 5 replies $thread->setLastPostId($postId); // This post is the last post $category = new Category(); $category->setId(1); $category->setPostCount(10); // Category has 10 total replies $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); $this->permissionService->method('getCategoryIdFromPost')->willReturn(1); $this->permissionService->method('hasCategoryPermission')->willReturn(false); $this->postMapper->expects($this->once()) ->method('find') ->willReturn($post); $this->postMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedPost) { $this->assertNotNull($updatedPost->getDeletedAt()); return $updatedPost; }); $this->threadMapper->expects($this->once()) ->method('find') ->willReturn($thread); $this->threadMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedThread) { // Thread post count should be decremented from 5 to 4 $this->assertEquals(4, $updatedThread->getPostCount()); return $updatedThread; }); // Mock finding the last post (not the deleted one) $lastPost = $this->createMockPost(2, 1, $userId, 'Last post'); $this->postMapper->expects($this->once()) ->method('findLatestByThreadId') ->with(1, $postId) ->willReturn($lastPost); $this->categoryMapper->expects($this->once()) ->method('find') ->willReturn($category); $this->categoryMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedCategory) { // Category post count should be decremented from 10 to 9 $this->assertEquals(9, $updatedCategory->getPostCount()); return $updatedCategory; }); $this->forumUserMapper->expects($this->once()) ->method('decrementPostCount') ->with($userId); $response = $this->controller->destroy($postId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); } public function testDestroyFirstPostDecrementsThreadCount(): void { $postId = 1; $userId = 'user1'; $post = $this->createMockPost($postId, 1, $userId, 'First post content'); $post->setIsFirstPost(true); // First post $thread = new Thread(); $thread->setId(1); $thread->setCategoryId(1); $thread->setPostCount(3); // Thread has 3 replies $thread->setLastPostId($postId); // This post is the last post $category = new Category(); $category->setId(1); $category->setPostCount(10); // Category has 10 total replies $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); $this->permissionService->method('getCategoryIdFromPost')->willReturn(1); $this->permissionService->method('hasCategoryPermission')->willReturn(false); $this->postMapper->expects($this->once()) ->method('find') ->willReturn($post); $this->postMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedPost) { $this->assertNotNull($updatedPost->getDeletedAt()); return $updatedPost; }); $this->threadMapper->expects($this->once()) ->method('find') ->willReturn($thread); $this->threadMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedThread) { // Thread post count should stay at 3 (first posts don't count) $this->assertEquals(3, $updatedThread->getPostCount()); return $updatedThread; }); $lastPost = $this->createMockPost(2, 1, $userId, 'Last post'); $this->postMapper->expects($this->once()) ->method('findLatestByThreadId') ->with(1, $postId) ->willReturn($lastPost); // Category mapper should not be called for first post deletion $this->categoryMapper->expects($this->never()) ->method('find'); $this->categoryMapper->expects($this->never()) ->method('update'); // First post deletion should decrement thread count, not post count $this->forumUserMapper->expects($this->once()) ->method('decrementThreadCount') ->with($userId); $this->forumUserMapper->expects($this->never()) ->method('decrementPostCount'); $response = $this->controller->destroy($postId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); } public function testByThreadPaginatedReturnsPostsSuccessfully(): void { $threadId = 1; $page = 1; $perPage = 20; // Create mock first post $firstPost = $this->createMockPost(1, $threadId, 'user1', 'First post content'); $firstPost->setIsFirstPost(true); // Create mock replies $reply1 = $this->createMockPost(2, $threadId, 'user1', 'Reply 1'); $reply2 = $this->createMockPost(3, $threadId, 'user2', 'Reply 2'); $replies = [$reply1, $reply2]; // Create mock BBCode $bbcode = new BBCode(); $bbcode->setId(1); $bbcode->setTag('b'); $bbcode->setReplacement('{content}'); $bbcode->setEnabled(true); // Mock user session (authenticated user) $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); $this->userSession->method('getUser')->willReturn($user); // Mock read marker (no existing marker = unread thread) $this->readMarkerMapper->expects($this->once()) ->method('findByUserAndThread') ->with('user1', $threadId) ->willThrowException(new DoesNotExistException('No read marker')); // Set up PostMapper expectations $this->postMapper->expects($this->once()) ->method('countRepliesByThreadId') ->with($threadId) ->willReturn(2); $this->postMapper->expects($this->once()) ->method('findFirstPostByThreadId') ->with($threadId) ->willReturn($firstPost); $this->postMapper->expects($this->once()) ->method('findRepliesByThreadId') ->with($threadId, $perPage, 0) ->willReturn($replies); $this->bbCodeMapper->expects($this->once()) ->method('findAllEnabled') ->willReturn([$bbcode]); $this->reactionMapper->expects($this->once()) ->method('findByPostIds') ->with([1, 2, 3]) ->willReturn([]); $this->userService->expects($this->once()) ->method('enrichMultipleUsers') ->willReturn([ 'user1' => ['userId' => 'user1', 'displayName' => 'User 1', 'roles' => []], 'user2' => ['userId' => 'user2', 'displayName' => 'User 2', 'roles' => []], ]); // Execute $response = $this->controller->byThreadPaginated($threadId, $page, $perPage); // Assert $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertArrayHasKey('firstPost', $data); $this->assertArrayHasKey('replies', $data); $this->assertArrayHasKey('pagination', $data); $this->assertEquals(1, $data['firstPost']['id']); $this->assertCount(2, $data['replies']); $this->assertEquals(1, $data['pagination']['page']); $this->assertEquals(20, $data['pagination']['perPage']); $this->assertEquals(2, $data['pagination']['total']); $this->assertEquals(1, $data['pagination']['totalPages']); } public function testByThreadPaginatedStartsAtLastPageForUnreadThread(): void { $threadId = 1; $perPage = 20; // Create mock first post $firstPost = $this->createMockPost(1, $threadId, 'user1', 'First post content'); $firstPost->setIsFirstPost(true); // Create 25 mock replies (2 pages) $replies = []; for ($i = 2; $i <= 26; $i++) { $reply = $this->createMockPost($i, $threadId, 'user1', "Reply $i"); $replies[] = $reply; } // Mock user session $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); $this->userSession->method('getUser')->willReturn($user); // Mock read marker - no existing marker (never read) $this->readMarkerMapper->expects($this->once()) ->method('findByUserAndThread') ->with('user1', $threadId) ->willThrowException(new DoesNotExistException('No read marker')); // 25 replies = 2 pages (20 per page) $this->postMapper->expects($this->once()) ->method('countRepliesByThreadId') ->with($threadId) ->willReturn(25); $this->postMapper->expects($this->once()) ->method('findFirstPostByThreadId') ->with($threadId) ->willReturn($firstPost); // When page=0, it should calculate startPage=2 (last page) and fetch from offset 20 $this->postMapper->expects($this->once()) ->method('findRepliesByThreadId') ->with($threadId, $perPage, 20) // offset = (2-1) * 20 = 20 ->willReturn(array_slice($replies, 20)); // Last 5 replies $this->bbCodeMapper->method('findAllEnabled')->willReturn([]); $this->reactionMapper->method('findByPostIds')->willReturn([]); $this->userService->method('enrichMultipleUsers')->willReturn([ 'user1' => ['userId' => 'user1', 'displayName' => 'User 1', 'roles' => []], ]); // Execute with page=0 (auto-calculate start page) $response = $this->controller->byThreadPaginated($threadId, 0, $perPage); // Assert $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals(2, $data['pagination']['page']); // Should be on last page $this->assertEquals(2, $data['pagination']['totalPages']); $this->assertEquals(2, $data['pagination']['startPage']); // Unread = last page $this->assertNull($data['pagination']['lastReadPostId']); } public function testByThreadPaginatedStartsAtOldestUnreadPage(): void { $threadId = 1; $perPage = 20; // Create mock first post $firstPost = $this->createMockPost(1, $threadId, 'user1', 'First post content'); $firstPost->setIsFirstPost(true); // Create mock oldest unread reply (ID 32, which would be on page 2) $oldestUnreadReply = $this->createMockPost(32, $threadId, 'user2', 'Oldest unread'); // Mock user session $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); $this->userSession->method('getUser')->willReturn($user); // Mock read marker - last read post ID is 31 $readMarker = new ReadMarker(); $readMarker->setUserId('user1'); $readMarker->setThreadId($threadId); $readMarker->setLastReadPostId(31); $readMarker->setReadAt(time()); $this->readMarkerMapper->expects($this->once()) ->method('findByUserAndThread') ->with('user1', $threadId) ->willReturn($readMarker); // 50 replies = 3 pages $this->postMapper->expects($this->once()) ->method('countRepliesByThreadId') ->with($threadId) ->willReturn(50); // Find oldest unread reply $this->postMapper->expects($this->once()) ->method('findOldestUnreadReply') ->with($threadId, 31) ->willReturn($oldestUnreadReply); // Get position of oldest unread reply (30 replies before it = page 2) $this->postMapper->expects($this->once()) ->method('getReplyPosition') ->with($threadId, 32) ->willReturn(30); // 0-indexed position 30 = page 2 (floor(30/20)+1) $this->postMapper->expects($this->once()) ->method('findFirstPostByThreadId') ->with($threadId) ->willReturn($firstPost); // Should fetch page 2 (offset 20) $this->postMapper->expects($this->once()) ->method('findRepliesByThreadId') ->with($threadId, $perPage, 20) ->willReturn([]); $this->bbCodeMapper->method('findAllEnabled')->willReturn([]); $this->reactionMapper->method('findByPostIds')->willReturn([]); $this->userService->method('enrichMultipleUsers')->willReturn([ 'user1' => ['userId' => 'user1', 'displayName' => 'User 1', 'roles' => []], ]); // Execute with page=0 (auto-calculate start page) $response = $this->controller->byThreadPaginated($threadId, 0, $perPage); // Assert $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals(2, $data['pagination']['page']); // Page with oldest unread $this->assertEquals(3, $data['pagination']['totalPages']); $this->assertEquals(2, $data['pagination']['startPage']); $this->assertEquals(31, $data['pagination']['lastReadPostId']); } public function testByThreadPaginatedGoesToLastPageWhenAllRead(): void { $threadId = 1; $perPage = 20; // Create mock first post $firstPost = $this->createMockPost(1, $threadId, 'user1', 'First post content'); $firstPost->setIsFirstPost(true); // Mock user session $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); $this->userSession->method('getUser')->willReturn($user); // Mock read marker - all posts read (last read = 50) $readMarker = new ReadMarker(); $readMarker->setUserId('user1'); $readMarker->setThreadId($threadId); $readMarker->setLastReadPostId(50); $readMarker->setReadAt(time()); $this->readMarkerMapper->expects($this->once()) ->method('findByUserAndThread') ->with('user1', $threadId) ->willReturn($readMarker); // 40 replies = 2 pages $this->postMapper->expects($this->once()) ->method('countRepliesByThreadId') ->with($threadId) ->willReturn(40); // No unread replies (all read) $this->postMapper->expects($this->once()) ->method('findOldestUnreadReply') ->with($threadId, 50) ->willReturn(null); $this->postMapper->expects($this->once()) ->method('findFirstPostByThreadId') ->with($threadId) ->willReturn($firstPost); // Should fetch last page (page 2, offset 20) $this->postMapper->expects($this->once()) ->method('findRepliesByThreadId') ->with($threadId, $perPage, 20) ->willReturn([]); $this->bbCodeMapper->method('findAllEnabled')->willReturn([]); $this->reactionMapper->method('findByPostIds')->willReturn([]); $this->userService->method('enrichMultipleUsers')->willReturn([ 'user1' => ['userId' => 'user1', 'displayName' => 'User 1', 'roles' => []], ]); // Execute with page=0 (auto-calculate start page) $response = $this->controller->byThreadPaginated($threadId, 0, $perPage); // Assert $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals(2, $data['pagination']['page']); // Last page $this->assertEquals(2, $data['pagination']['totalPages']); $this->assertEquals(2, $data['pagination']['startPage']); // All read = last page $this->assertEquals(50, $data['pagination']['lastReadPostId']); } public function testByThreadPaginatedWorksForGuestUser(): void { $threadId = 1; $page = 1; $perPage = 20; // Create mock first post $firstPost = $this->createMockPost(1, $threadId, 'user1', 'First post content'); $firstPost->setIsFirstPost(true); // Mock user session - guest (no user) $this->userSession->method('getUser')->willReturn(null); // 10 replies = 1 page $this->postMapper->expects($this->once()) ->method('countRepliesByThreadId') ->with($threadId) ->willReturn(10); // Read marker should not be called for guests $this->readMarkerMapper->expects($this->never()) ->method('findByUserAndThread'); $this->postMapper->expects($this->once()) ->method('findFirstPostByThreadId') ->with($threadId) ->willReturn($firstPost); $this->postMapper->expects($this->once()) ->method('findRepliesByThreadId') ->with($threadId, $perPage, 0) ->willReturn([]); $this->bbCodeMapper->method('findAllEnabled')->willReturn([]); $this->reactionMapper->method('findByPostIds')->willReturn([]); $this->userService->method('enrichMultipleUsers')->willReturn([ 'user1' => ['userId' => 'user1', 'displayName' => 'User 1', 'roles' => []], ]); // Execute $response = $this->controller->byThreadPaginated($threadId, $page, $perPage); // Assert $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals(1, $data['pagination']['page']); $this->assertEquals(1, $data['pagination']['startPage']); // Guests start at last page $this->assertNull($data['pagination']['lastReadPostId']); } public function testByThreadPaginatedRespectsExplicitPageParameter(): void { $threadId = 1; $perPage = 20; // Create mock first post $firstPost = $this->createMockPost(1, $threadId, 'user1', 'First post content'); $firstPost->setIsFirstPost(true); // Mock user session $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); $this->userSession->method('getUser')->willReturn($user); // Mock read marker - no existing marker $this->readMarkerMapper->expects($this->once()) ->method('findByUserAndThread') ->willThrowException(new DoesNotExistException('No read marker')); // 60 replies = 3 pages $this->postMapper->expects($this->once()) ->method('countRepliesByThreadId') ->with($threadId) ->willReturn(60); $this->postMapper->expects($this->once()) ->method('findFirstPostByThreadId') ->with($threadId) ->willReturn($firstPost); // Request page 2 explicitly (even though startPage would be 3) $this->postMapper->expects($this->once()) ->method('findRepliesByThreadId') ->with($threadId, $perPage, 20) // Page 2 = offset 20 ->willReturn([]); $this->bbCodeMapper->method('findAllEnabled')->willReturn([]); $this->reactionMapper->method('findByPostIds')->willReturn([]); $this->userService->method('enrichMultipleUsers')->willReturn([ 'user1' => ['userId' => 'user1', 'displayName' => 'User 1', 'roles' => []], ]); // Execute with explicit page=2 $response = $this->controller->byThreadPaginated($threadId, 2, $perPage); // Assert $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals(2, $data['pagination']['page']); // Requested page 2 $this->assertEquals(3, $data['pagination']['totalPages']); $this->assertEquals(3, $data['pagination']['startPage']); // StartPage is still 3 (last page for unread) } }