Files
nextcloud-forum/tests/unit/Controller/PostControllerTest.php

1178 lines
38 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Forum\Tests\Controller;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Controller\PostController;
use OCA\Forum\Db\BBCode;
use OCA\Forum\Db\BBCodeMapper;
use OCA\Forum\Db\Category;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\ForumUser;
use OCA\Forum\Db\ForumUserMapper;
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;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCA\Forum\Service\BBCodeService;
use OCA\Forum\Service\GuestService;
use OCA\Forum\Service\NotificationService;
use OCA\Forum\Service\PermissionService;
use OCA\Forum\Service\PostEnrichmentService;
use OCA\Forum\Service\PostHistoryService;
use OCA\Forum\Service\UserPreferencesService;
use OCA\Forum\Service\UserService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class PostControllerTest extends TestCase {
private PostController $controller;
/** @var PostMapper&MockObject */
private PostMapper $postMapper;
/** @var ThreadMapper&MockObject */
private ThreadMapper $threadMapper;
/** @var CategoryMapper&MockObject */
private CategoryMapper $categoryMapper;
/** @var ForumUserMapper&MockObject */
private ForumUserMapper $forumUserMapper;
/** @var ReactionMapper&MockObject */
private ReactionMapper $reactionMapper;
/** @var BBCodeService&MockObject */
private BBCodeService $bbCodeService;
/** @var BBCodeMapper&MockObject */
private BBCodeMapper $bbCodeMapper;
/** @var PermissionService&MockObject */
private PermissionService $permissionService;
/** @var ReadMarkerMapper&MockObject */
private ReadMarkerMapper $readMarkerMapper;
/** @var NotificationService&MockObject */
private NotificationService $notificationService;
/** @var PostEnrichmentService&MockObject */
private PostEnrichmentService $postEnrichmentService;
/** @var PostHistoryService&MockObject */
private PostHistoryService $postHistoryService;
/** @var UserService&MockObject */
private UserService $userService;
/** @var UserPreferencesService&MockObject */
private UserPreferencesService $userPreferencesService;
/** @var ThreadSubscriptionMapper&MockObject */
private ThreadSubscriptionMapper $threadSubscriptionMapper;
/** @var GuestService&MockObject */
private GuestService $guestService;
/** @var IUserSession&MockObject */
private IUserSession $userSession;
/** @var LoggerInterface&MockObject */
private LoggerInterface $logger;
/** @var IRequest&MockObject */
private IRequest $request;
protected function setUp(): void {
$this->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->userPreferencesService = $this->createMock(UserPreferencesService::class);
$this->threadSubscriptionMapper = $this->createMock(ThreadSubscriptionMapper::class);
$this->guestService = $this->createMock(GuestService::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->userPreferencesService,
$this->threadSubscriptionMapper,
$this->guestService,
$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('<strong>{content}</strong>');
$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('<strong>{content}</strong>');
$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->setEntityId($threadId);
$readMarker->setMarkerType(ReadMarker::TYPE_THREAD);
$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->setEntityId($threadId);
$readMarker->setMarkerType(ReadMarker::TYPE_THREAD);
$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)
}
}