bookmarkRepo = $this->createMock(BookmarkRepositoryInterface::class); $this->groupRepo = $this->createMock(BookmarkGroupRepositoryInterface::class); $this->navigationRepo = $this->createMock(BookmarkNavigationRepositoryInterface::class); $this->service = new BookmarkService($this->bookmarkRepo, $this->groupRepo, $this->navigationRepo); } public function testSaveBookmarkHappyPath(): void { $this->bookmarkRepo->method('countByUser')->willReturn(0); $this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(1); $this->bookmarkRepo->method('create')->willReturn(42); $result = $this->service->saveBookmark(1, 'Home', 'admin', null); $this->assertTrue($result['ok']); $this->assertSame('created', $result['mode']); $this->assertSame(42, $result['bookmark']['id']); $this->assertSame('Home', $result['bookmark']['name']); $this->assertSame('admin', $result['bookmark']['url']); $this->assertNull($result['bookmark']['group_id']); $this->assertArrayNotHasKey('icon', $result['bookmark']); } public function testSaveBookmarkUpsertsExistingBookmarkByExactUrl(): void { $this->bookmarkRepo->expects($this->once())->method('findByUserAndUrl')->with(1, 'admin/users?a=1&b=2')->willReturn([ 'id' => 9, 'url' => 'admin/users?a=1&b=2', ]); $this->bookmarkRepo->expects($this->once())->method('update')->with( 9, 1, [ 'name' => 'Users', 'group_id' => null, ] )->willReturn(true); $this->bookmarkRepo->expects($this->never())->method('create'); $result = $this->service->saveBookmark(1, 'Users', 'admin/users?b=2&a=1', null); $this->assertTrue($result['ok']); $this->assertSame('updated', $result['mode']); $this->assertSame(9, $result['bookmark']['id']); $this->assertSame('admin/users?a=1&b=2', $result['bookmark']['url']); } public function testSaveBookmarkUpsertsExistingBookmarkAndMovesToNewGroupAtEnd(): void { $this->groupRepo->expects($this->once())->method('findById')->with(3, 1)->willReturn([ 'id' => 3, 'name' => 'Sales', ]); $this->bookmarkRepo->expects($this->once())->method('findByUserAndUrl')->with(1, 'admin/users')->willReturn([ 'id' => 9, 'url' => 'admin/users', 'group_id' => 1, ]); $this->bookmarkRepo->expects($this->once())->method('nextBookmarkSortOrder')->with(1, 3)->willReturn(12); $this->bookmarkRepo->expects($this->once())->method('update')->with( 9, 1, [ 'name' => 'Users', 'group_id' => 3, 'sort_order' => 12, ] )->willReturn(true); $this->bookmarkRepo->expects($this->never())->method('create'); $result = $this->service->saveBookmark(1, 'Users', 'admin/users', 3); $this->assertTrue($result['ok']); $this->assertSame('updated', $result['mode']); } public function testSaveBookmarkCreatesWhenNoExactUrlMatch(): void { $this->bookmarkRepo->expects($this->once())->method('findByUserAndUrl')->with(1, 'admin/users?a=1&b=2')->willReturn(false); $this->bookmarkRepo->method('countByUser')->willReturn(0); $this->navigationRepo->method('nextRootSortOrder')->willReturn(1); $this->bookmarkRepo->expects($this->once())->method('create')->willReturn(7); $result = $this->service->saveBookmark(1, 'Users', 'admin/users?a=1&b=2', null); $this->assertTrue($result['ok']); $this->assertSame('created', $result['mode']); $this->assertSame(7, $result['bookmark']['id']); } public function testSaveBookmarkRejectsEmptyName(): void { $result = $this->service->saveBookmark(1, '', 'admin', null); $this->assertFalse($result['ok']); $this->assertSame('name_required', $result['error']); } public function testSaveBookmarkRejectsEmptyUrl(): void { $result = $this->service->saveBookmark(1, 'Test', '', null); $this->assertFalse($result['ok']); $this->assertSame('url_required', $result['error']); } public function testSaveBookmarkRejectsAbsoluteUrl(): void { $result = $this->service->saveBookmark(1, 'External', 'https://evil.com', null); $this->assertFalse($result['ok']); $this->assertSame('url_required', $result['error']); } public function testSaveBookmarkRejectsProtocolRelativeUrl(): void { $result = $this->service->saveBookmark(1, 'External', '//evil.com', null); $this->assertFalse($result['ok']); $this->assertSame('url_required', $result['error']); } public function testSaveBookmarkMaxLimitReached(): void { $this->bookmarkRepo->method('countByUser')->willReturn(50); $result = $this->service->saveBookmark(1, 'Test', 'admin', null); $this->assertFalse($result['ok']); $this->assertSame('max_reached', $result['error']); } public function testSaveBookmarkWithGroupValidatesOwnership(): void { $this->bookmarkRepo->method('countByUser')->willReturn(0); $this->groupRepo->expects($this->any())->method('findById')->with(5, 1)->willReturn(false); $result = $this->service->saveBookmark(1, 'Test', 'admin', 5); $this->assertFalse($result['ok']); $this->assertSame('group_not_found', $result['error']); } public function testSaveBookmarkWithValidGroup(): void { $this->bookmarkRepo->method('countByUser')->willReturn(0); $this->bookmarkRepo->expects($this->once())->method('nextBookmarkSortOrder')->with(1, 5)->willReturn(7); $this->bookmarkRepo->expects($this->once())->method('create')->with([ 'user_id' => 1, 'group_id' => 5, 'name' => 'Test', 'url' => 'admin', 'sort_order' => 7, ])->willReturn(10); $this->groupRepo->expects($this->any())->method('findById')->with(5, 1)->willReturn(['id' => 5, 'name' => 'Dev']); $result = $this->service->saveBookmark(1, 'Test', 'admin', 5); $this->assertTrue($result['ok']); $this->assertSame(5, $result['bookmark']['group_id']); } public function testUpdateBookmarkNotFound(): void { $this->bookmarkRepo->method('findById')->willReturn(false); $result = $this->service->updateBookmark(1, 999, ['name' => 'New Name']); $this->assertFalse($result['ok']); $this->assertSame('not_found', $result['error']); } public function testUpdateBookmarkRejectsEmptyName(): void { $this->bookmarkRepo->method('findById')->willReturn(['id' => 1, 'name' => 'Old']); $result = $this->service->updateBookmark(1, 1, ['name' => '']); $this->assertFalse($result['ok']); $this->assertSame('name_required', $result['error']); } public function testUpdateBookmarkNoChanges(): void { $this->bookmarkRepo->method('findById')->willReturn(['id' => 1, 'name' => 'Old']); $result = $this->service->updateBookmark(1, 1, []); $this->assertTrue($result['ok']); } public function testUpdateBookmarkGroupChangeAppendsToTargetGroup(): void { $this->bookmarkRepo->expects($this->once())->method('findById')->with(5, 1)->willReturn([ 'id' => 5, 'name' => 'Users', 'group_id' => 1, ]); $this->groupRepo->expects($this->once())->method('findById')->with(2, 1)->willReturn([ 'id' => 2, 'name' => 'New', ]); $this->bookmarkRepo->expects($this->once())->method('nextBookmarkSortOrder')->with(1, 2)->willReturn(9); $this->bookmarkRepo->expects($this->once())->method('update')->with(5, 1, [ 'group_id' => 2, 'sort_order' => 9, ])->willReturn(true); $result = $this->service->updateBookmark(1, 5, ['group_id' => 2]); $this->assertTrue($result['ok']); } public function testUpdateBookmarkMoveToUngroupedUsesRootSortOrder(): void { $this->bookmarkRepo->expects($this->once())->method('findById')->with(5, 1)->willReturn([ 'id' => 5, 'name' => 'Users', 'group_id' => 2, ]); $this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(17); $this->bookmarkRepo->expects($this->once())->method('update')->with(5, 1, [ 'group_id' => null, 'sort_order' => 17, ])->willReturn(true); $result = $this->service->updateBookmark(1, 5, ['group_id' => null]); $this->assertTrue($result['ok']); } public function testDeleteBookmark(): void { $this->bookmarkRepo->expects($this->any())->method('delete')->with(5, 1)->willReturn(true); $this->assertTrue($this->service->deleteBookmark(1, 5)); } public function testSaveGroupHappyPath(): void { $this->groupRepo->method('countByUser')->willReturn(0); $this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(4); $this->groupRepo->expects($this->once())->method('create')->with([ 'user_id' => 1, 'name' => 'Development', 'icon' => 'bi-people', 'sort_order' => 4, ])->willReturn(3); $result = $this->service->saveGroup(1, 'Development', 'bi-people'); $this->assertTrue($result['ok']); $this->assertSame('created', $result['mode']); $this->assertSame(3, $result['group']['id']); $this->assertSame('Development', $result['group']['name']); $this->assertSame('bi-people', $result['group']['icon']); } public function testSaveGroupRejectsEmptyName(): void { $result = $this->service->saveGroup(1, '', 'bi-folder'); $this->assertFalse($result['ok']); $this->assertSame('name_required', $result['error']); } public function testSaveGroupFallsBackToDefaultIcon(): void { $this->groupRepo->method('countByUser')->willReturn(0); $this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(1); $this->groupRepo->expects($this->once())->method('create')->with([ 'user_id' => 1, 'name' => 'Development', 'icon' => 'bi-folder', 'sort_order' => 1, ])->willReturn(11); $result = $this->service->saveGroup(1, 'Development', 'invalid-icon'); $this->assertTrue($result['ok']); $this->assertSame('bi-folder', $result['group']['icon']); } public function testSaveGroupMaxLimitReached(): void { $this->groupRepo->method('countByUser')->willReturn(10); $result = $this->service->saveGroup(1, 'New Group', 'bi-folder'); $this->assertFalse($result['ok']); $this->assertSame('max_reached', $result['error']); } public function testSaveGroupRenameExisting(): void { $this->groupRepo->expects($this->any())->method('findById')->with(5, 1)->willReturn(['id' => 5, 'name' => 'Old']); $this->groupRepo->expects($this->once()) ->method('update') ->with(5, 1, ['name' => 'Renamed', 'icon' => 'bi-heart']) ->willReturn(true); $result = $this->service->saveGroup(1, 'Renamed', 'bi-heart', 5); $this->assertTrue($result['ok']); $this->assertSame('updated', $result['mode']); $this->assertSame(5, $result['group']['id']); $this->assertSame('Renamed', $result['group']['name']); $this->assertSame('bi-heart', $result['group']['icon']); } public function testDeleteGroupUnsetsGroupAndDeletes(): void { $this->bookmarkRepo->expects($this->once())->method('listByGroup')->with(1, 5)->willReturn([ ['id' => 10, 'name' => 'A', 'url' => 'admin', 'group_id' => 5, 'sort_order' => 1], ['id' => 11, 'name' => 'B', 'url' => 'admin/users', 'group_id' => 5, 'sort_order' => 2], ]); $this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(20); $this->bookmarkRepo->expects($this->exactly(2))->method('unsetGroupId') ->willReturnCallback(fn (int $bmId, int $userId, int $sort) => match ([$bmId, $userId]) { [10, 1] => $sort === 20, [11, 1] => $sort === 21, default => false, }); $this->groupRepo->expects($this->once())->method('delete')->with(5, 1)->willReturn(true); $this->assertTrue($this->service->deleteGroup(1, 5)); } public function testDeleteGroupReturnsFalseWhenUnsetFails(): void { $this->bookmarkRepo->expects($this->once())->method('listByGroup')->with(1, 5)->willReturn([ ['id' => 10, 'name' => 'A', 'url' => 'admin', 'group_id' => 5, 'sort_order' => 1], ]); $this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(20); $this->bookmarkRepo->expects($this->once())->method('unsetGroupId')->with(10, 1, 20)->willReturn(false); $this->groupRepo->expects($this->never())->method('delete'); $this->assertFalse($this->service->deleteGroup(1, 5)); } public function testDeleteGroupWithNoBookmarksDeletesDirectly(): void { $this->bookmarkRepo->expects($this->once())->method('listByGroup')->with(1, 5)->willReturn([]); $this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(1); $this->bookmarkRepo->expects($this->never())->method('unsetGroupId'); $this->groupRepo->expects($this->once())->method('delete')->with(5, 1)->willReturn(true); $this->assertTrue($this->service->deleteGroup(1, 5)); } public function testReorderGroupsDelegatesToRepository(): void { $pairs = [ ['id' => 4, 'sort_order' => 1], ['id' => 6, 'sort_order' => 2], ]; $this->groupRepo->expects($this->once())->method('updateSortOrders')->with(1, $pairs)->willReturn(true); $this->assertTrue($this->service->reorderGroups(1, $pairs)); } public function testReorderBookmarksReturnsFalseOnRepositoryFailure(): void { $pairs = [ ['id' => 11, 'sort_order' => 1], ['id' => 7, 'sort_order' => 2], ]; $this->bookmarkRepo->expects($this->once())->method('updateSortOrders')->with(1, $pairs)->willReturn(false); $this->assertFalse($this->service->reorderBookmarks(1, $pairs)); } public function testReorderRootDelegatesToNavigationRepository(): void { $pairs = [ ['kind' => 'bookmark', 'id' => 11, 'sort_order' => 1], ['kind' => 'group', 'id' => 2, 'sort_order' => 2], ]; $this->navigationRepo->expects($this->once()) ->method('updateRootSortOrders') ->with(1, $pairs) ->willReturn(true); $this->assertTrue($this->service->reorderRoot(1, $pairs)); } public function testListGroupedForUser(): void { $this->groupRepo->method('listByUser')->willReturn([ ['id' => 1, 'name' => 'Dev', 'icon' => 'bi-folder', 'sort_order' => 0], ]); $this->bookmarkRepo->method('listByUser')->willReturn([ ['id' => 10, 'name' => 'Home', 'url' => 'admin', 'group_id' => null, 'sort_order' => 0], ['id' => 11, 'name' => 'Users', 'url' => 'admin/users', 'group_id' => 1, 'sort_order' => 0], ]); $result = $this->service->listGroupedForUser(1); $this->assertCount(1, $result['groups']); $this->assertSame('Dev', $result['groups'][0]['name']); $this->assertSame('bi-folder', $result['groups'][0]['icon']); $this->assertCount(1, $result['groups'][0]['bookmarks']); $this->assertSame('Users', $result['groups'][0]['bookmarks'][0]['name']); $this->assertArrayNotHasKey('icon', $result['groups'][0]['bookmarks'][0]); $this->assertCount(1, $result['ungrouped']); $this->assertSame('Home', $result['ungrouped'][0]['name']); $this->assertArrayNotHasKey('icon', $result['ungrouped'][0]); } public function testReorderFromItemsRejectsInvalidType(): void { $result = $this->service->reorderFromItems(1, 'invalid', []); $this->assertFalse($result['ok']); $this->assertSame('invalid_type', $result['error']); } public function testReorderFromItemsRejectsEmptyItems(): void { $result = $this->service->reorderFromItems(1, 'bookmarks', []); $this->assertFalse($result['ok']); $this->assertSame('invalid_items', $result['error']); } public function testReorderFromItemsBookmarksHappyPath(): void { $this->bookmarkRepo->expects($this->once())->method('updateSortOrders')->with(1, [ ['id' => 3, 'sort_order' => 1], ['id' => 7, 'sort_order' => 2], ])->willReturn(true); $result = $this->service->reorderFromItems(1, 'bookmarks', [ ['id' => 3, 'sort_order' => 1], ['id' => 7, 'sort_order' => 2], ]); $this->assertTrue($result['ok']); } public function testReorderRootFromItemsHappyPath(): void { $this->navigationRepo->expects($this->once())->method('updateRootSortOrders')->with(1, [ ['kind' => 'group', 'id' => 2, 'sort_order' => 1], ['kind' => 'bookmark', 'id' => 5, 'sort_order' => 2], ])->willReturn(true); $result = $this->service->reorderRootFromItems(1, [ ['kind' => 'group', 'id' => 2, 'sort_order' => 1], ['kind' => 'bookmark', 'id' => 5, 'sort_order' => 2], ]); $this->assertTrue($result['ok']); } public function testReorderRootFromItemsRejectsEmptyItems(): void { $result = $this->service->reorderRootFromItems(1, []); $this->assertFalse($result['ok']); $this->assertSame('invalid_items', $result['error']); } public function testAllowedGroupIconsReturnsNonEmptyList(): void { $icons = BookmarkService::allowedGroupIcons(); $this->assertNotEmpty($icons); $this->assertContains('bi-folder', $icons); $this->assertContains('bi-house', $icons); } }