request = $this->createMock(IRequest::class); $this->catHeaderMapper = $this->createMock(CatHeaderMapper::class); $this->categoryMapper = $this->createMock(CategoryMapper::class); $this->categoryPermMapper = $this->createMock(CategoryPermMapper::class); $this->threadMapper = $this->createMock(ThreadMapper::class); $this->readMarkerMapper = $this->createMock(ReadMarkerMapper::class); $this->roleMapper = $this->createMock(RoleMapper::class); $this->permissionService = $this->createMock(PermissionService::class); // By default, grant access to all categories (tests that need filtering can override) $this->permissionService->method('getAccessibleCategories') ->willReturnCallback(function () { // Return IDs 1-100 to cover all test categories return range(1, 100); }); $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new CategoryController( Application::APP_ID, $this->request, $this->catHeaderMapper, $this->categoryMapper, $this->categoryPermMapper, $this->threadMapper, $this->readMarkerMapper, $this->roleMapper, $this->permissionService, $this->userSession, $this->logger ); } public function testIndexReturnsHeadersWithNestedCategories(): void { $header1 = $this->createCatHeader(1, 'General'); $header2 = $this->createCatHeader(2, 'Support'); $category1 = $this->createCategory(1, 1, 'Announcements'); $category2 = $this->createCategory(2, 1, 'General Discussion'); $category3 = $this->createCategory(3, 2, 'Help Desk'); $this->catHeaderMapper->expects($this->once()) ->method('findAll') ->willReturn([$header1, $header2]); $this->categoryMapper->expects($this->once()) ->method('findAll') ->willReturn([$category1, $category2, $category3]); $response = $this->controller->index(); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertIsArray($data); $this->assertCount(2, $data); $this->assertArrayHasKey('categories', $data[0]); $this->assertCount(2, $data[0]['categories']); $this->assertCount(1, $data[1]['categories']); } public function testIndexIncludesLastActivityAtFromThreadMapper(): void { $header = $this->createCatHeader(1, 'General'); $category1 = $this->createCategory(1, 1, 'Announcements'); $category2 = $this->createCategory(2, 1, 'Discussion'); $this->catHeaderMapper->method('findAll')->willReturn([$header]); $this->categoryMapper->method('findAll')->willReturn([$category1, $category2]); $lastActivityMap = [1 => 1700000000, 2 => 1700001000]; $this->threadMapper->expects($this->once()) ->method('getLastActivityByCategories') ->willReturn($lastActivityMap); $response = $this->controller->index(); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $categories = $data[0]['categories']; $this->assertEquals(1700000000, $categories[0]['lastActivityAt']); $this->assertEquals(1700001000, $categories[1]['lastActivityAt']); } public function testIndexReturnsNullLastActivityAtWhenNoThreads(): void { $header = $this->createCatHeader(1, 'General'); $category = $this->createCategory(1, 1, 'Empty Category'); $this->catHeaderMapper->method('findAll')->willReturn([$header]); $this->categoryMapper->method('findAll')->willReturn([$category]); $this->threadMapper->method('getLastActivityByCategories') ->willReturn([]); // No threads in any category $response = $this->controller->index(); $data = $response->getData(); $this->assertNull($data[0]['categories'][0]['lastActivityAt']); } public function testIndexIncludesReadAtForAuthenticatedUser(): void { $header = $this->createCatHeader(1, 'General'); $category1 = $this->createCategory(1, 1, 'Announcements'); $category2 = $this->createCategory(2, 1, 'Discussion'); $this->catHeaderMapper->method('findAll')->willReturn([$header]); $this->categoryMapper->method('findAll')->willReturn([$category1, $category2]); $this->threadMapper->method('getLastActivityByCategories')->willReturn([]); $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); $this->userSession->method('getUser')->willReturn($user); $marker = new ReadMarker(); $marker->setEntityId(1); $marker->setReadAt(1700000500); $this->readMarkerMapper->expects($this->once()) ->method('findCategoryMarkersByUserId') ->with('user1') ->willReturn([$marker]); $response = $this->controller->index(); $data = $response->getData(); $categories = $data[0]['categories']; $this->assertEquals(1700000500, $categories[0]['readAt']); $this->assertNull($categories[1]['readAt']); // No marker for category 2 } public function testIndexReturnsNullReadAtForGuest(): void { $header = $this->createCatHeader(1, 'General'); $category = $this->createCategory(1, 1, 'Announcements'); $this->catHeaderMapper->method('findAll')->willReturn([$header]); $this->categoryMapper->method('findAll')->willReturn([$category]); $this->threadMapper->method('getLastActivityByCategories')->willReturn([]); $this->userSession->method('getUser')->willReturn(null); $this->readMarkerMapper->expects($this->never()) ->method('findCategoryMarkersByUserId'); $response = $this->controller->index(); $data = $response->getData(); $this->assertNull($data[0]['categories'][0]['readAt']); } public function testIndexCategoryUnreadWhenActivityAfterReadMarker(): void { $header = $this->createCatHeader(1, 'General'); $category = $this->createCategory(1, 1, 'Announcements'); $this->catHeaderMapper->method('findAll')->willReturn([$header]); $this->categoryMapper->method('findAll')->willReturn([$category]); // Last post activity at timestamp 1000 $this->threadMapper->method('getLastActivityByCategories') ->willReturn([1 => 1700001000]); $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); $this->userSession->method('getUser')->willReturn($user); // User last read at timestamp 500 (before activity) $marker = new ReadMarker(); $marker->setEntityId(1); $marker->setReadAt(1700000500); $this->readMarkerMapper->method('findCategoryMarkersByUserId') ->willReturn([$marker]); $response = $this->controller->index(); $data = $response->getData(); $cat = $data[0]['categories'][0]; // lastActivityAt > readAt means unread $this->assertGreaterThan($cat['readAt'], $cat['lastActivityAt']); } public function testIndexCategoryReadWhenReadMarkerAfterActivity(): void { $header = $this->createCatHeader(1, 'General'); $category = $this->createCategory(1, 1, 'Announcements'); $this->catHeaderMapper->method('findAll')->willReturn([$header]); $this->categoryMapper->method('findAll')->willReturn([$category]); // Last post activity at timestamp 500 $this->threadMapper->method('getLastActivityByCategories') ->willReturn([1 => 1700000500]); $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); $this->userSession->method('getUser')->willReturn($user); // User last read at timestamp 1000 (after activity) $marker = new ReadMarker(); $marker->setEntityId(1); $marker->setReadAt(1700001000); $this->readMarkerMapper->method('findCategoryMarkersByUserId') ->willReturn([$marker]); $response = $this->controller->index(); $data = $response->getData(); $cat = $data[0]['categories'][0]; // readAt >= lastActivityAt means read $this->assertGreaterThanOrEqual($cat['lastActivityAt'], $cat['readAt']); } public function testByHeaderReturnsCategoriesForHeader(): void { $headerId = 1; $category1 = $this->createCategory(1, $headerId, 'Category 1'); $category2 = $this->createCategory(2, $headerId, 'Category 2'); $this->categoryMapper->expects($this->once()) ->method('findByHeaderId') ->with($headerId) ->willReturn([$category1, $category2]); $response = $this->controller->byHeader($headerId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertIsArray($data); $this->assertCount(2, $data); } public function testShowReturnsCategorySuccessfully(): void { $categoryId = 1; $category = $this->createCategory($categoryId, 1, 'Test Category'); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); $response = $this->controller->show($categoryId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals($categoryId, $data['id']); $this->assertEquals('Test Category', $data['name']); } public function testShowReturnsNotFoundWhenCategoryDoesNotExist(): void { $categoryId = 999; $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willThrowException(new DoesNotExistException('Category not found')); $response = $this->controller->show($categoryId); $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); $this->assertEquals(['error' => 'Category not found'], $response->getData()); } public function testBySlugReturnsCategorySuccessfully(): void { $slug = 'test-category'; $category = $this->createCategory(1, 1, 'Test Category'); $category->setSlug($slug); $this->categoryMapper->expects($this->once()) ->method('findBySlug') ->with($slug) ->willReturn($category); $response = $this->controller->bySlug($slug); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals($slug, $data['slug']); } public function testBySlugReturnsNotFoundWhenCategoryDoesNotExist(): void { $slug = 'non-existent-category'; $this->categoryMapper->expects($this->once()) ->method('findBySlug') ->with($slug) ->willThrowException(new DoesNotExistException('Category not found')); $response = $this->controller->bySlug($slug); $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); $this->assertEquals(['error' => 'Category not found'], $response->getData()); } public function testCreateCategorySuccessfully(): void { $headerId = 1; $name = 'New Category'; $slug = 'new-category'; $description = 'A new category'; $sortOrder = 10; $createdCategory = $this->createCategory(1, $headerId, $name); $createdCategory->setSlug($slug); $createdCategory->setDescription($description); $createdCategory->setSortOrder($sortOrder); $this->categoryMapper->expects($this->once()) ->method('insert') ->willReturnCallback(function ($category) use ($createdCategory) { return $createdCategory; }); $response = $this->controller->create($headerId, $name, $slug, $description, $sortOrder); $this->assertEquals(Http::STATUS_CREATED, $response->getStatus()); $data = $response->getData(); $this->assertEquals(1, $data['id']); $this->assertEquals($name, $data['name']); $this->assertEquals($slug, $data['slug']); $this->assertEquals($description, $data['description']); $this->assertEquals($sortOrder, $data['sortOrder']); } public function testUpdateCategorySuccessfully(): void { $categoryId = 1; $newName = 'Updated Category'; $category = $this->createCategory($categoryId, 1, 'Original Name'); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); $this->categoryMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedCategory) use ($newName) { $this->assertEquals($newName, $updatedCategory->getName()); return $updatedCategory; }); $response = $this->controller->update($categoryId, null, $newName); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals($categoryId, $data['id']); } public function testUpdateCategoryWithMultipleFields(): void { $categoryId = 1; $newName = 'Updated Name'; $newDescription = 'Updated Description'; $newSlug = 'updated-slug'; $newSortOrder = 20; $category = $this->createCategory($categoryId, 1, 'Original Name'); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); $this->categoryMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedCategory) use ($newName, $newDescription, $newSlug, $newSortOrder) { $this->assertEquals($newName, $updatedCategory->getName()); $this->assertEquals($newDescription, $updatedCategory->getDescription()); $this->assertEquals($newSlug, $updatedCategory->getSlug()); $this->assertEquals($newSortOrder, $updatedCategory->getSortOrder()); return $updatedCategory; }); $response = $this->controller->update($categoryId, null, $newName, $newDescription, $newSlug, $newSortOrder); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); } public function testUpdateCategoryHeaderId(): void { $categoryId = 1; $originalHeaderId = 1; $newHeaderId = 2; $category = $this->createCategory($categoryId, $originalHeaderId, 'Test Category'); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); $this->categoryMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($updatedCategory) use ($newHeaderId) { $this->assertEquals($newHeaderId, $updatedCategory->getHeaderId()); return $updatedCategory; }); $response = $this->controller->update($categoryId, $newHeaderId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals($newHeaderId, $data['headerId']); } public function testUpdateCategoryReturnsNotFoundWhenCategoryDoesNotExist(): void { $categoryId = 999; $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willThrowException(new DoesNotExistException('Category not found')); $response = $this->controller->update($categoryId, null, 'New Name'); $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); $this->assertEquals(['error' => 'Category not found'], $response->getData()); } public function testGetThreadCountReturnsCountSuccessfully(): void { $categoryId = 1; $expectedCount = 42; $category = $this->createCategory($categoryId, 1, 'Test Category'); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); $this->threadMapper->expects($this->once()) ->method('countByCategoryId') ->with($categoryId) ->willReturn($expectedCount); $response = $this->controller->getThreadCount($categoryId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertEquals(['count' => $expectedCount], $data); } public function testGetThreadCountReturnsNotFoundWhenCategoryDoesNotExist(): void { $categoryId = 999; $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willThrowException(new DoesNotExistException('Category not found')); $response = $this->controller->getThreadCount($categoryId); $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); $this->assertEquals(['error' => 'Category not found'], $response->getData()); } public function testDestroyCategorySuccessfullyWithMigration(): void { $categoryId = 1; $targetCategoryId = 2; $threadsAffected = 5; $category = $this->createCategory($categoryId, 1, 'Category to Delete'); $targetCategory = $this->createCategory($targetCategoryId, 1, 'Target Category'); $this->categoryMapper->expects($this->exactly(2)) ->method('find') ->willReturnMap([ [$categoryId, $category], [$targetCategoryId, $targetCategory], ]); $this->threadMapper->expects($this->once()) ->method('moveToCategoryId') ->with($categoryId, $targetCategoryId) ->willReturn($threadsAffected); $this->categoryMapper->expects($this->once()) ->method('delete') ->with($category); $response = $this->controller->destroy($categoryId, $targetCategoryId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['success']); $this->assertEquals($threadsAffected, $data['threadsAffected']); } public function testDestroyCategorySuccessfullyWithSoftDelete(): void { $categoryId = 1; $threadsAffected = 3; $category = $this->createCategory($categoryId, 1, 'Category to Delete'); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); $this->threadMapper->expects($this->once()) ->method('softDeleteByCategoryId') ->with($categoryId) ->willReturn($threadsAffected); $this->categoryMapper->expects($this->once()) ->method('delete') ->with($category); $response = $this->controller->destroy($categoryId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['success']); $this->assertEquals($threadsAffected, $data['threadsAffected']); } public function testDestroyCategoryReturnsNotFoundWhenCategoryDoesNotExist(): void { $categoryId = 999; $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willThrowException(new DoesNotExistException('Category not found')); $response = $this->controller->destroy($categoryId); $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); $this->assertEquals(['error' => 'Category not found'], $response->getData()); } public function testDestroyCategoryReturnsNotFoundWhenTargetCategoryDoesNotExist(): void { $categoryId = 1; $targetCategoryId = 999; $category = $this->createCategory($categoryId, 1, 'Category to Delete'); $this->categoryMapper->expects($this->exactly(2)) ->method('find') ->willReturnCallback(function ($id) use ($categoryId, $category, $targetCategoryId) { if ($id === $categoryId) { return $category; } if ($id === $targetCategoryId) { throw new DoesNotExistException('Target category not found'); } }); $response = $this->controller->destroy($categoryId, $targetCategoryId); $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); $this->assertEquals(['error' => 'Target category not found'], $response->getData()); } public function testCheckPermissionReturnsTrue(): void { $categoryId = 1; $permission = 'canView'; $userId = 'user1'; $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); $this->permissionService->expects($this->once()) ->method('hasCategoryPermission') ->with($userId, $categoryId, $permission) ->willReturn(true); $response = $this->controller->checkPermission($categoryId, $permission); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['hasPermission']); } public function testCheckPermissionReturnsFalseWhenNoPermission(): void { $categoryId = 1; $permission = 'canModerate'; $userId = 'user1'; $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); $this->permissionService->expects($this->once()) ->method('hasCategoryPermission') ->with($userId, $categoryId, $permission) ->willReturn(false); $response = $this->controller->checkPermission($categoryId, $permission); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertFalse($data['hasPermission']); } public function testCheckPermissionReturnsTrueForAdmin(): void { $categoryId = 1; $permission = 'canModerate'; $userId = 'admin1'; $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); $this->permissionService->expects($this->once()) ->method('hasCategoryPermission') ->with($userId, $categoryId, $permission) ->willReturn(true); $response = $this->controller->checkPermission($categoryId, $permission); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['hasPermission']); } public function testGetPermissionsReturnsPermissionsSuccessfully(): void { $categoryId = 1; // Note: Only non-admin roles (2, 3) are returned - Admin role is excluded $perm1 = new CategoryPerm(); $perm1->setId(1); $perm1->setCategoryId($categoryId); $perm1->setTargetType('role'); $perm1->setTargetId('2'); $perm1->setCanView(true); $perm1->setCanPost(true); $perm1->setCanReply(true); $perm1->setCanModerate(false); $perm2 = new CategoryPerm(); $perm2->setId(2); $perm2->setCategoryId($categoryId); $perm2->setTargetType('role'); $perm2->setTargetId('3'); $perm2->setCanView(true); $perm2->setCanPost(false); $perm2->setCanReply(false); $perm2->setCanModerate(false); $this->categoryPermMapper->expects($this->once()) ->method('findByCategoryIdExcludingAdmin') ->with($categoryId) ->willReturn([$perm1, $perm2]); $response = $this->controller->getPermissions($categoryId); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertIsArray($data); $this->assertCount(2, $data); $this->assertEquals('role', $data[0]['targetType']); $this->assertEquals('2', $data[0]['targetId']); $this->assertTrue($data[0]['canView']); $this->assertFalse($data[0]['canModerate']); } public function testUpdatePermissionsSuccessfully(): void { $categoryId = 1; $permissions = [ ['roleId' => 2, 'canView' => true, 'canPost' => true, 'canModerate' => false], ['roleId' => 3, 'canView' => true, 'canPost' => false, 'canModerate' => true], ]; $category = $this->createCategory($categoryId, 1, 'Test Category'); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); $this->categoryPermMapper->expects($this->once()) ->method('deleteByCategoryId') ->with($categoryId); $this->roleMapper->expects($this->exactly(4)) ->method('find') ->willReturnMap([ [2, (function () { $r = new Role(); $r->setId(2); $r->setRoleType(Role::ROLE_TYPE_MODERATOR); return $r; })()], [3, (function () { $r = new Role(); $r->setId(3); $r->setRoleType(Role::ROLE_TYPE_MODERATOR); return $r; })()], ]); $this->categoryPermMapper->expects($this->exactly(2)) ->method('insert') ->willReturnCallback(function ($perm) { if ($perm->getTargetId() === '2') { $this->assertTrue($perm->getCanPost()); $this->assertTrue($perm->getCanReply()); } else { $this->assertFalse($perm->getCanPost()); $this->assertFalse($perm->getCanReply()); } return $perm; }); $response = $this->controller->updatePermissions($categoryId, $permissions); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['success']); } public function testUpdatePermissionsFiltersOutAdminRole(): void { $categoryId = 1; $permissions = [ ['roleId' => 1, 'canView' => true, 'canModerate' => true], // Admin - should be filtered ['roleId' => 2, 'canView' => true, 'canModerate' => false], ['roleId' => 3, 'canView' => true, 'canModerate' => false], ]; $category = $this->createCategory($categoryId, 1, 'Test Category'); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); // Mock role lookups for admin check $adminRole = new Role(); $adminRole->setId(1); $adminRole->setName('Admin'); $adminRole->setRoleType(Role::ROLE_TYPE_ADMIN); $moderatorRole = new Role(); $moderatorRole->setId(2); $moderatorRole->setName('Moderator'); $moderatorRole->setRoleType(Role::ROLE_TYPE_MODERATOR); $userRole = new Role(); $userRole->setId(3); $userRole->setName('User'); $userRole->setRoleType(Role::ROLE_TYPE_DEFAULT); // roleMapper->find() is called: // - 3 times during filtering phase (for roles 1, 2, 3) // - 2 times during insertion phase (for roles 2, 3 only - role 1 is filtered out) $this->roleMapper->expects($this->exactly(5)) ->method('find') ->willReturnMap([ [1, $adminRole], [2, $moderatorRole], [3, $userRole], ]); $this->categoryPermMapper->expects($this->once()) ->method('deleteByCategoryId') ->with($categoryId); // Should only insert 2 permissions (Admin role ID 1 is filtered out) $this->categoryPermMapper->expects($this->exactly(2)) ->method('insert') ->willReturnCallback(function ($perm) { // Verify that Admin role (ID 1) is never inserted $this->assertNotEquals('1', $perm->getTargetId()); return $perm; }); $response = $this->controller->updatePermissions($categoryId, $permissions); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['success']); } public function testUpdatePermissionsReturnsNotFoundWhenCategoryDoesNotExist(): void { $categoryId = 999; $permissions = [ ['roleId' => 1, 'canView' => true, 'canModerate' => false], ]; $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willThrowException(new DoesNotExistException('Category not found')); $response = $this->controller->updatePermissions($categoryId, $permissions); $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); $this->assertEquals(['error' => 'Category not found'], $response->getData()); } public function testReorderUpdatesCategories(): void { $categories = [ ['id' => 1, 'sortOrder' => 2], ['id' => 2, 'sortOrder' => 1], ]; $category1 = $this->createCategory(1, 1, 'Category 1'); $category2 = $this->createCategory(2, 1, 'Category 2'); $this->categoryMapper->expects($this->exactly(2)) ->method('find') ->willReturnCallback(function ($id) use ($category1, $category2) { return $id === 1 ? $category1 : $category2; }); $this->categoryMapper->expects($this->exactly(2)) ->method('update') ->willReturnCallback(function ($category) use ($categories) { if ($category->getId() === 1) { $this->assertEquals(2, $category->getSortOrder()); } else { $this->assertEquals(1, $category->getSortOrder()); } return $category; }); $response = $this->controller->reorder($categories); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['success']); } public function testUpdatePermissionsEnforcesNoModerateForGuest(): void { $categoryId = 1; $guestRoleId = 4; $category = $this->createCategory($categoryId, 1, 'Test Category'); $guestRole = new Role(); $guestRole->setId($guestRoleId); $guestRole->setName('Guest'); $guestRole->setRoleType(Role::ROLE_TYPE_GUEST); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); $this->categoryPermMapper->expects($this->once()) ->method('deleteByCategoryId') ->with($categoryId); // roleMapper->find() is called twice: // - Once during filtering phase // - Once during insertion phase $this->roleMapper->expects($this->exactly(2)) ->method('find') ->with($guestRoleId) ->willReturn($guestRole); $this->categoryPermMapper->expects($this->once()) ->method('insert') ->willReturnCallback(function ($perm) use ($guestRoleId, $categoryId) { $this->assertEquals($categoryId, $perm->getCategoryId()); $this->assertEquals((string)$guestRoleId, $perm->getTargetId()); $this->assertTrue($perm->getCanView()); // Verify guest role never has moderate permission, even if requested $this->assertFalse($perm->getCanModerate()); return $perm; }); $permissions = [ ['roleId' => $guestRoleId, 'canView' => true, 'canModerate' => true], // Try to enable moderate ]; $response = $this->controller->updatePermissions($categoryId, $permissions); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['success']); } public function testUpdatePermissionsAllowsModerateForNonGuest(): void { $categoryId = 1; $moderatorRoleId = 2; $category = $this->createCategory($categoryId, 1, 'Test Category'); $moderatorRole = new Role(); $moderatorRole->setId($moderatorRoleId); $moderatorRole->setName('Moderator'); $moderatorRole->setRoleType(Role::ROLE_TYPE_MODERATOR); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); $this->categoryPermMapper->expects($this->once()) ->method('deleteByCategoryId') ->with($categoryId); // roleMapper->find() is called twice: // - Once during filtering phase // - Once during insertion phase $this->roleMapper->expects($this->exactly(2)) ->method('find') ->with($moderatorRoleId) ->willReturn($moderatorRole); $this->categoryPermMapper->expects($this->once()) ->method('insert') ->willReturnCallback(function ($perm) use ($moderatorRoleId, $categoryId) { $this->assertEquals($categoryId, $perm->getCategoryId()); $this->assertEquals((string)$moderatorRoleId, $perm->getTargetId()); $this->assertTrue($perm->getCanView()); // Verify non-guest role CAN have moderate permission $this->assertTrue($perm->getCanModerate()); return $perm; }); $permissions = [ ['roleId' => $moderatorRoleId, 'canView' => true, 'canModerate' => true], ]; $response = $this->controller->updatePermissions($categoryId, $permissions); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['success']); } public function testUpdatePermissionsEnforcesNoModerateForDefault(): void { $categoryId = 1; $defaultRoleId = 3; $category = $this->createCategory($categoryId, 1, 'Test Category'); $defaultRole = new Role(); $defaultRole->setId($defaultRoleId); $defaultRole->setName('User'); $defaultRole->setRoleType(Role::ROLE_TYPE_DEFAULT); $this->categoryMapper->expects($this->once()) ->method('find') ->with($categoryId) ->willReturn($category); $this->categoryPermMapper->expects($this->once()) ->method('deleteByCategoryId') ->with($categoryId); // roleMapper->find() is called twice: // - Once during filtering phase // - Once during insertion phase $this->roleMapper->expects($this->exactly(2)) ->method('find') ->with($defaultRoleId) ->willReturn($defaultRole); $this->categoryPermMapper->expects($this->once()) ->method('insert') ->willReturnCallback(function ($perm) use ($defaultRoleId, $categoryId) { $this->assertEquals($categoryId, $perm->getCategoryId()); $this->assertEquals((string)$defaultRoleId, $perm->getTargetId()); $this->assertTrue($perm->getCanView()); // Verify default role never has moderate permission, even if requested $this->assertFalse($perm->getCanModerate()); return $perm; }); $permissions = [ ['roleId' => $defaultRoleId, 'canView' => true, 'canModerate' => true], // Try to enable moderate ]; $response = $this->controller->updatePermissions($categoryId, $permissions); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['success']); } private function createCatHeader(int $id, string $name): CatHeader { $header = new CatHeader(); $header->setId($id); $header->setName($name); $header->setSortOrder(0); $header->setCreatedAt(time()); return $header; } private function createCategory(int $id, int $headerId, string $name): Category { $category = new Category(); $category->setId($id); $category->setHeaderId($headerId); $category->setName($name); $category->setSlug("category-$id"); $category->setDescription(null); $category->setSortOrder(0); $category->setThreadCount(0); $category->setPostCount(0); $category->setCreatedAt(time()); $category->setUpdatedAt(time()); return $category; } }