diff --git a/lib/Controller/PostController.php b/lib/Controller/PostController.php index 510ee32..3ab1e34 100644 --- a/lib/Controller/PostController.php +++ b/lib/Controller/PostController.php @@ -109,6 +109,132 @@ class PostController extends OCSController { } } + /** + * Get paginated posts by thread with first post separated + * + * @param int $threadId Thread ID + * @param int $page Page number (1-indexed) + * @param int $perPage Number of replies per page + * @return DataResponse|null, replies: list>, pagination: array{page: int, perPage: int, total: int, totalPages: int, startPage: int, lastReadPostId: int|null}}, array{}> + * + * 200: Posts returned with pagination metadata + */ + #[NoAdminRequired] + #[PublicPage] + #[RequirePermission('canView', resourceType: 'category', resourceIdFromThreadId: 'threadId')] + #[ApiRoute(verb: 'GET', url: '/api/threads/{threadId}/posts/paginated')] + public function byThreadPaginated(int $threadId, int $page = 0, int $perPage = 20): DataResponse { + try { + // Get current user ID + $currentUserId = $this->userSession->getUser()?->getUID(); + + // Count total replies (excluding first post) + $totalReplies = $this->postMapper->countRepliesByThreadId($threadId); + $totalPages = max(1, (int)ceil($totalReplies / $perPage)); + + // Determine the start page based on read status + $startPage = $totalPages; // Default: last page (newest) for unread threads + $lastReadPostId = null; + + if ($currentUserId !== null) { + try { + $readMarker = $this->readMarkerMapper->findByUserAndThread($currentUserId, $threadId); + $lastReadPostId = $readMarker->getLastReadPostId(); + + // Find the oldest unread reply + $oldestUnreadReply = $this->postMapper->findOldestUnreadReply($threadId, $lastReadPostId); + if ($oldestUnreadReply !== null) { + // Calculate which page this reply is on + $position = $this->postMapper->getReplyPosition($threadId, $oldestUnreadReply->getId()); + $startPage = (int)floor($position / $perPage) + 1; + } else { + // All replies are read, go to last page + $startPage = $totalPages; + } + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + // No read marker = never read = go to last page (newest) + $startPage = $totalPages; + } + } + + // If page=0, use the calculated start page + if ($page === 0) { + $page = $startPage; + } + + // Ensure page is within valid range + $page = max(1, min($page, $totalPages)); + $offset = ($page - 1) * $perPage; + + // Fetch first post + $firstPost = $this->postMapper->findFirstPostByThreadId($threadId); + + // Fetch replies for the current page + $replies = $this->postMapper->findRepliesByThreadId($threadId, $perPage, $offset); + + // Prefetch BBCodes once for all posts to avoid repeated queries + $bbcodes = $this->bbCodeMapper->findAllEnabled(); + + // Collect all posts for reaction fetching + $allPosts = $firstPost !== null ? array_merge([$firstPost], $replies) : $replies; + $postIds = array_map(fn ($p) => $p->getId(), $allPosts); + + // Fetch reactions for all posts at once (performance optimization) + $reactions = $this->reactionMapper->findByPostIds($postIds); + + // Group reactions by post ID + $reactionsByPostId = []; + foreach ($reactions as $reaction) { + $postId = $reaction->getPostId(); + if (!isset($reactionsByPostId[$postId])) { + $reactionsByPostId[$postId] = []; + } + $reactionsByPostId[$postId][] = $reaction; + } + + // Extract unique author IDs + $authorIds = array_unique(array_map(fn ($p) => $p->getAuthorId(), $allPosts)); + + // Batch fetch author data (includes roles) + $authors = $this->userService->enrichMultipleUsers($authorIds); + + // Enrich first post + $enrichedFirstPost = null; + if ($firstPost !== null) { + $firstPostReactions = $reactionsByPostId[$firstPost->getId()] ?? []; + $enrichedFirstPost = $this->postEnrichmentService->enrichPost( + $firstPost, + $bbcodes, + $firstPostReactions, + $currentUserId, + $authors[$firstPost->getAuthorId()] ?? null + ); + } + + // Enrich replies + $enrichedReplies = array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors) { + $postReactions = $reactionsByPostId[$p->getId()] ?? []; + return $this->postEnrichmentService->enrichPost($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()] ?? null); + }, $replies); + + return new DataResponse([ + 'firstPost' => $enrichedFirstPost, + 'replies' => $enrichedReplies, + 'pagination' => [ + 'page' => $page, + 'perPage' => $perPage, + 'total' => $totalReplies, + 'totalPages' => $totalPages, + 'startPage' => $startPage, + 'lastReadPostId' => $lastReadPostId, + ], + ]); + } catch (\Exception $e) { + $this->logger->error('Error fetching paginated posts by thread: ' . $e->getMessage()); + return new DataResponse(['error' => 'Failed to fetch posts'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + /** * Get posts by author * diff --git a/lib/Db/PostMapper.php b/lib/Db/PostMapper.php index ce6a2e0..ad2f639 100644 --- a/lib/Db/PostMapper.php +++ b/lib/Db/PostMapper.php @@ -230,6 +230,131 @@ class PostMapper extends QBMapper { return (int)($row['count'] ?? 0); } + /** + * Find the first post in a thread + * + * @param int $threadId Thread ID + * @return Post|null First post or null if not found + */ + public function findFirstPostByThreadId(int $threadId): ?Post { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('is_first_post', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNull('deleted_at')) + ->setMaxResults(1); + + try { + return $this->findEntity($qb); + } catch (DoesNotExistException $e) { + return null; + } + } + + /** + * Find replies (non-first posts) in a thread with pagination + * + * @param int $threadId Thread ID + * @param int $limit Maximum results + * @param int $offset Results offset + * @return array + */ + public function findRepliesByThreadId(int $threadId, int $limit = 50, int $offset = 0): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNull('deleted_at')) + ->orderBy('created_at', 'ASC') + ->setMaxResults($limit) + ->setFirstResult($offset); + return $this->findEntities($qb); + } + + /** + * Count replies (non-first posts) in a thread + * + * @param int $threadId Thread ID + * @return int Number of replies + */ + public function countRepliesByThreadId(int $threadId): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*', 'count')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNull('deleted_at')); + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + return (int)($row['count'] ?? 0); + } + + /** + * Find the oldest unread reply in a thread + * + * @param int $threadId Thread ID + * @param int $afterPostId Post ID to look after (last read post ID) + * @return Post|null The oldest unread reply or null if all are read + */ + public function findOldestUnreadReply(int $threadId, int $afterPostId): ?Post { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->gt('id', $qb->createNamedParameter($afterPostId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('deleted_at')) + ->orderBy('created_at', 'ASC') + ->setMaxResults(1); + + try { + return $this->findEntity($qb); + } catch (DoesNotExistException $e) { + return null; + } + } + + /** + * Get the position (0-indexed) of a reply in the replies list ordered by created_at ASC + * + * @param int $threadId Thread ID + * @param int $postId Post ID to find position of + * @return int Position (0-indexed) + */ + public function getReplyPosition(int $threadId, int $postId): int { + // First get the created_at of the target post + $targetQb = $this->db->getQueryBuilder(); + $targetQb->select('created_at') + ->from($this->getTableName()) + ->where($targetQb->expr()->eq('id', $targetQb->createNamedParameter($postId, IQueryBuilder::PARAM_INT))); + $targetResult = $targetQb->executeQuery(); + $targetRow = $targetResult->fetch(); + $targetResult->closeCursor(); + + if (!$targetRow) { + return 0; + } + + $targetCreatedAt = (int)$targetRow['created_at']; + + // Count replies created before the target post + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*', 'position')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNull('deleted_at')) + ->andWhere($qb->expr()->lt('created_at', $qb->createNamedParameter($targetCreatedAt, IQueryBuilder::PARAM_INT))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + return (int)($row['position'] ?? 0); + } + /** * Search posts by content (replies only, excluding first posts) * diff --git a/openapi.json b/openapi.json index 8a95d6f..98fbef0 100644 --- a/openapi.json +++ b/openapi.json @@ -3500,6 +3500,159 @@ } } }, + "/ocs/v2.php/apps/forum/api/threads/{threadId}/posts/paginated": { + "get": { + "operationId": "post-by-thread-paginated", + "summary": "Get paginated posts by thread with first post separated", + "tags": [ + "post" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "threadId", + "in": "path", + "description": "Thread ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number (1-indexed)", + "schema": { + "type": "integer", + "format": "int64", + "default": 0 + } + }, + { + "name": "perPage", + "in": "query", + "description": "Number of replies per page", + "schema": { + "type": "integer", + "format": "int64", + "default": 20 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Posts returned with pagination metadata", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "firstPost", + "replies", + "pagination" + ], + "properties": { + "firstPost": { + "type": "object", + "nullable": true, + "additionalProperties": { + "type": "object" + } + }, + "replies": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "pagination": { + "type": "object", + "required": [ + "page", + "perPage", + "total", + "totalPages", + "startPage", + "lastReadPostId" + ], + "properties": { + "page": { + "type": "integer", + "format": "int64" + }, + "perPage": { + "type": "integer", + "format": "int64" + }, + "total": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int64" + }, + "startPage": { + "type": "integer", + "format": "int64" + }, + "lastReadPostId": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forum/api/users/{authorId}/posts": { "get": { "operationId": "post-by-author", diff --git a/src/components/Pagination.vue b/src/components/Pagination.vue new file mode 100644 index 0000000..a071082 --- /dev/null +++ b/src/components/Pagination.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/src/views/ThreadView.vue b/src/views/ThreadView.vue index 0e25cb1..732b8bc 100644 --- a/src/views/ThreadView.vue +++ b/src/views/ThreadView.vue @@ -182,16 +182,48 @@ - -
-
+ +
+ +
+ + +
+ + + + +
+ + {{ strings.loading }} +
+ +
- -
-

{{ strings.showingPosts(posts.length) }}

-
+ +
(), canModerate: false, isEditingTitle: false, @@ -375,7 +416,6 @@ export default defineComponent({ lockedMessage: t('forum', 'This thread is locked. Only moderators can post replies.'), guestMessage: t('forum', 'You must be signed in to reply to this thread.'), signInToReply: t('forum', 'Sign in to reply'), - showingPosts: (count: number) => n('forum', 'Showing %n post', 'Showing %n posts', count), lockThread: t('forum', 'Lock thread'), unlockThread: t('forum', 'Unlock thread'), pinThread: t('forum', 'Pin thread'), @@ -462,55 +502,87 @@ export default defineComponent({ } }, - async fetchPosts(): Promise { + async fetchPosts(page: number = 0): Promise { try { - // Fetch existing read marker before loading posts - await this.fetchReadMarker() + interface PaginatedResponse { + firstPost: Post | null + replies: Post[] + pagination: { + page: number + perPage: number + total: number + totalPages: number + startPage: number + lastReadPostId: number | null + } + } - const resp = await ocs.get(`/threads/${this.thread!.id}/posts`, { - params: { - limit: this.limit, - offset: this.offset, + const resp = await ocs.get( + `/threads/${this.thread!.id}/posts/paginated`, + { + params: { + page, + perPage: this.perPage, + }, }, - }) - this.posts = resp.data || [] + ) + + const data = resp.data + if (data) { + this.firstPost = data.firstPost + this.replies = data.replies || [] + this.currentPage = data.pagination.page + this.totalPages = data.pagination.totalPages + this.lastReadPostId = data.pagination.lastReadPostId + } // Mark thread as read up to the last post in the current view - if (this.posts.length > 0) { + const allPosts = this.getAllPosts() + if (allPosts.length > 0) { await this.markAsRead() } - // Scroll to post if hash is present in URL + // Scroll to post if hash is present in URL, otherwise scroll to top of replies await this.$nextTick() - this.scrollToPostFromHash() + if (window.location.hash || this.$route.hash) { + this.scrollToPostFromHash() + } } catch (e) { console.error('Failed to fetch posts', e) throw new Error(t('forum', 'Failed to load posts')) } }, - async fetchReadMarker(): Promise { + async handlePageChange(newPage: number): Promise { + if (newPage === this.currentPage) return + try { - // Guests don't have read markers - if (this.userId === null) { - return - } + this.loadingReplies = true + this.currentPage = newPage + await this.fetchPosts(newPage) - if (!this.thread) { - return + // Scroll to the top of the replies section + await this.$nextTick() + const repliesSection = this.$el.querySelector('.replies-section') + if (repliesSection) { + repliesSection.scrollIntoView({ behavior: 'smooth', block: 'start' }) } - - const resp = await ocs.get<{ threadId: number; lastReadPostId: number; readAt: number }>( - `/threads/${this.thread.id}/read-marker`, - ) - this.lastReadPostId = resp.data?.lastReadPostId || null } catch (e) { - // Not found or error - treat as no read marker - this.lastReadPostId = null - console.debug('No read marker found', e) + console.error('Failed to load page', e) + } finally { + this.loadingReplies = false } }, + getAllPosts(): Post[] { + const posts: Post[] = [] + if (this.firstPost) { + posts.push(this.firstPost) + } + posts.push(...this.replies) + return posts + }, + isPostUnread(post: Post): boolean { // Guests see everything as read if (this.userId === null) { @@ -533,16 +605,25 @@ export default defineComponent({ } // Get the last post ID from the current view - const lastPost = this.posts[this.posts.length - 1] + const allPosts = this.getAllPosts() + const lastPost = allPosts[allPosts.length - 1] if (!lastPost || !this.thread) { return } + // Only update if the new post is newer than what we've already read + if (this.lastReadPostId !== null && lastPost.id <= this.lastReadPostId) { + return + } + // Send request to mark thread as read await ocs.post('/read-markers', { threadId: this.thread.id, lastReadPostId: lastPost.id, }) + + // Update local state so posts appear as read immediately + this.lastReadPostId = lastPost.id } catch (e) { // Silently fail - marking as read is not critical console.debug('Failed to mark thread as read', e) @@ -595,11 +676,18 @@ export default defineComponent({ }) if (response.data) { - // Update the post in the local posts array - const index = this.posts.findIndex((p) => p.id === data.post.id) - if (index !== -1) { - // Preserve reactions when updating - this.posts[index] = { ...response.data, reactions: this.posts[index]?.reactions || [] } + // Update the post in the correct array (firstPost or replies) + if (this.firstPost && this.firstPost.id === data.post.id) { + this.firstPost = { ...response.data, reactions: this.firstPost.reactions || [] } + } else { + const index = this.replies.findIndex((p) => p.id === data.post.id) + if (index !== -1) { + // Preserve reactions when updating + this.replies[index] = { + ...response.data, + reactions: this.replies[index]?.reactions || [], + } + } } // Exit edit mode @@ -623,7 +711,7 @@ export default defineComponent({ async handleDelete(post: Post): Promise { try { // If this is the first post, we're deleting the entire thread - const isFirstPost = this.posts.length > 0 && this.posts[0]?.id === post.id + const isFirstPost = this.firstPost && this.firstPost.id === post.id if (isFirstPost) { // Delete thread @@ -644,10 +732,10 @@ export default defineComponent({ // Delete post optimistically await ocs.delete(`/posts/${post.id}`) - // Remove the post from the local array without refreshing - const index = this.posts.findIndex((p) => p.id === post.id) + // Remove the post from the local replies array without refreshing + const index = this.replies.findIndex((p) => p.id === post.id) if (index !== -1) { - this.posts.splice(index, 1) + this.replies.splice(index, 1) } showSuccess(t('forum', 'Post deleted')) @@ -702,16 +790,16 @@ export default defineComponent({ content, }) - // Append the new post to the existing posts array + // After submitting a reply, go to the last page and refresh if (response.data) { - // Add empty reactions array to the new post - const newPost = { ...response.data, reactions: [] } - this.posts.push(newPost) - // Clear the form only on success if (replyForm && typeof replyForm.clear === 'function') { replyForm.clear() } + + // Reload the last page to show the new reply + // Set page to a high number so it gets clamped to the last page + await this.fetchPosts(999999) } } catch (e) { console.error('Failed to submit reply', e) @@ -1101,9 +1189,24 @@ export default defineComponent({ gap: 12px; } - .pagination-info { - text-align: center; - padding: 12px; + .first-post-section { + // First post section styling + } + + .replies-section { + // Replies section with pagination + } + + .replies-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + } + + .pagination-top, + .pagination-bottom { + padding: 8px 0; } } diff --git a/tests/unit/Controller/PostControllerTest.php b/tests/unit/Controller/PostControllerTest.php index 44d08c0..cea2cf3 100644 --- a/tests/unit/Controller/PostControllerTest.php +++ b/tests/unit/Controller/PostControllerTest.php @@ -14,6 +14,7 @@ use OCA\Forum\Db\Post; use OCA\Forum\Db\PostMapper; use OCA\Forum\Db\Reaction; use OCA\Forum\Db\ReactionMapper; +use OCA\Forum\Db\ReadMarker; use OCA\Forum\Db\ReadMarkerMapper; use OCA\Forum\Db\Thread; use OCA\Forum\Db\ThreadMapper; @@ -752,4 +753,388 @@ class PostControllerTest extends TestCase { $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) + } }