feat: extract bookmarks as standalone module with MintyPHP\Module\Bookmarks namespace

Moves bookmarks from core hardcoding into modules/bookmarks/ as a
fully self-contained module, following the same pattern as addressbook.

Module contributions via platform slots:
- aside.tab_panel: bookmark sidebar panel
- topbar.right_item: bookmark toggle button
- layout.body_end_template: bookmark/group dialogs
- layout.head_style: bookmark CSS (form + sidebar)
- runtime.component: bookmark-save and bookmark-panel (phase: late)
- search.resource_item: bookmarks in global search (user-scoped via {{userId}})

Backend fully in module namespace (MintyPHP\Module\Bookmarks\*):
- Service: BookmarkService, BookmarkServicesFactory, BookmarkRepositoryFactory
- Repository: BookmarkRepository, BookmarkGroupRepository, BookmarkNavigationRepository
- Support: BookmarkUrlNormalizer
- Providers: BookmarksSessionProvider, BookmarksLayoutProvider, BookmarksSearchProvider

Core cleanup:
- Removed all bookmark-specific markup from core templates
- Removed core DI registrations for bookmark services
- Removed core bookmark pages, JS, CSS
- AuthSessionTenantContextService delegates to module SessionProvider

Architecture guards:
- NoBookmarksHardcodingTest: verifies zero bookmark references in core
- testModuleClassesUseModuleNamespace: prevents core namespace leakage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:20:20 +01:00
parent c7b8fd516a
commit 4871c6032e
37 changed files with 1514 additions and 294 deletions

View File

@@ -0,0 +1,227 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class FrontendComponentRuntimeContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testEntrypointsUseRuntimeRegistrationWithoutSideeffectComponentImports(): void
{
$defaultEntry = $this->readProjectFile('web/js/app-init.js');
$loginEntry = $this->readProjectFile('web/js/app-login-init.js');
$this->assertStringContainsString("import { createComponentRuntime } from './core/app-component-runtime.js';", $defaultEntry);
$this->assertStringContainsString("import { createComponentRuntime } from './core/app-component-runtime.js';", $loginEntry);
$this->assertDoesNotMatchRegularExpression('/import\s+[\'"]\.\/components\//', $defaultEntry);
$this->assertDoesNotMatchRegularExpression('/import\s+[\'"]\.\/components\//', $loginEntry);
$this->assertStringContainsString("runtime.register('sidebar-toggle', initSidebarToggle", $defaultEntry);
$this->assertStringContainsString("runtime.register('global-search', initGlobalSearch", $defaultEntry);
$this->assertStringContainsString("await mountModuleComponentsByPhase('late');", $defaultEntry);
$this->assertStringContainsString('moduleRuntimeComponents', $defaultEntry);
$this->assertStringContainsString("selector: '[data-app-component=\"tabs\"]'", $defaultEntry);
$this->assertStringContainsString('runtime.mountAll();', $defaultEntry);
$this->assertStringContainsString("runtime.register('password-hints', initPasswordHints", $loginEntry);
$this->assertStringContainsString("runtime.register('password-toggle', initPasswordToggles", $loginEntry);
$this->assertStringContainsString("runtime.register('login-submit-lock', initLoginSubmitLock", $loginEntry);
$this->assertStringContainsString('runtime.mountAll();', $loginEntry);
}
public function testRuntimeMigratedComponentsDoNotAutoInitialize(): void
{
$componentFiles = [
'web/js/components/app-flash-auto-dismiss.js',
'web/js/components/app-fslightbox-refresh.js',
'web/js/components/app-password-hints.js',
'web/js/components/app-copy-badge.js',
'web/js/components/app-multiselect-init.js',
'web/js/components/app-nav-history.js',
'web/js/components/app-toggle-sidebar.js',
'web/js/components/app-toggle-aside-panels.js',
'web/js/components/app-toggle-details-aside.js',
'web/js/components/app-global-search.js',
'web/js/components/app-theme-toggle.js',
'web/js/components/app-contrast-toggle.js',
'web/js/components/app-confirm-actions.js',
'web/js/components/app-details-state.js',
'web/js/components/app-tenant-switcher.js',
'web/js/components/app-color-default-toggle.js',
'web/js/components/app-custom-field-options-toggle.js',
'web/js/components/app-tenant-sso-toggle.js',
'web/js/components/app-settings-telemetry.js',
'web/js/components/app-standard-detail-page.js',
'web/js/components/app-auto-submit.js',
'modules/bookmarks/web/js/components/app-bookmark-save.js',
'modules/bookmarks/web/js/components/app-bookmark-panel.js',
'web/js/components/app-password-toggle.js',
'web/js/components/app-page-editor.js',
];
foreach ($componentFiles as $file) {
$content = $this->readProjectFile($file);
$this->assertStringNotContainsString('DOMContentLoaded', $content, $file);
$this->assertStringNotContainsString("if (document.readyState === 'loading')", $content, $file);
}
}
public function testMigratedComponentsExposeDestroyCleanupPaths(): void
{
$sidebar = $this->readProjectFile('web/js/components/app-toggle-sidebar.js');
$aside = $this->readProjectFile('web/js/components/app-toggle-aside-panels.js');
$search = $this->readProjectFile('web/js/components/app-global-search.js');
$bookmarkSave = $this->readProjectFile('modules/bookmarks/web/js/components/app-bookmark-save.js');
$bookmarkPanel = $this->readProjectFile('modules/bookmarks/web/js/components/app-bookmark-panel.js');
$multiSelect = $this->readProjectFile('web/js/components/app-multiselect-init.js');
$this->assertStringContainsString('destroy: () => {', $sidebar);
$this->assertStringContainsString("cleanupFns.push(() => document.removeEventListener('keydown', onShortcut));", $sidebar);
$this->assertStringContainsString('destroy: () => {', $aside);
$this->assertStringContainsString("cleanupFns.push(() => document.removeEventListener('keydown', onKeyDown));", $aside);
$this->assertStringContainsString('const destroy = () => {', $search);
$this->assertStringContainsString("document.removeEventListener('keydown', onDocumentKeydown);", $search);
$this->assertStringContainsString('const destroy = () => {', $bookmarkSave);
$this->assertStringContainsString('abortController.abort();', $bookmarkSave);
$this->assertStringContainsString('const destroy = () => {', $bookmarkPanel);
$this->assertStringContainsString('abortController.abort();', $bookmarkPanel);
$this->assertStringNotContainsString('export const init =', $bookmarkSave);
$this->assertStringNotContainsString('export const init =', $bookmarkPanel);
$this->assertStringContainsString('return {', $multiSelect);
$this->assertStringContainsString('destroy: () => {', $multiSelect);
}
public function testBookmarkLegacyEntrypointFileIsRemoved(): void
{
$this->assertFileDoesNotExist($this->projectRootPath() . '/web/js/components/app-bookmark-init.js');
}
public function testRuntimeStorageContractUsesSharedUiStorageHelper(): void
{
$files = [
'web/js/components/app-toggle-sidebar.js',
'web/js/components/app-toggle-aside-panels.js',
'web/js/components/app-toggle-details-aside.js',
'web/js/components/app-contrast-toggle.js',
'web/js/components/app-tabs.js',
'web/js/core/app-details-open-state.js',
'web/js/core/app-grid-factory.js',
];
foreach ($files as $file) {
$content = $this->readProjectFile($file);
$this->assertStringNotContainsString('window.localStorage', $content, $file);
$this->assertStringContainsString('createUiStorage', $content, $file);
}
}
public function testHostBoundComponentsExposeDataAppComponentMarkupHosts(): void
{
$defaultTemplate = $this->readProjectFile('templates/default.phtml');
$loginTemplate = $this->readProjectFile('templates/login.phtml');
$flashPartial = $this->readProjectFile('templates/partials/app-flash.phtml');
$topbarPartial = $this->readProjectFile('templates/partials/app-topbar.phtml');
$asideIconBar = $this->readProjectFile('templates/partials/app-main-aside-icon-bar.phtml');
$asidePartial = $this->readProjectFile('templates/partials/app-main-aside.phtml');
$bookmarkDialog = $this->readProjectFile('modules/bookmarks/templates/bookmark-dialogs.phtml');
$bookmarkPanelTemplate = $this->readProjectFile('modules/bookmarks/templates/aside-bookmarks-panel.phtml');
$sessionWarningDialog = $this->readProjectFile('templates/partials/app-session-warning-dialog.phtml');
$this->assertStringContainsString('id="page-config-app-components"', $defaultTemplate);
$this->assertStringContainsString('id="page-config-login-components"', $loginTemplate);
$this->assertStringNotContainsString("assetVersion('js/components/app-bookmark-init.js')", $defaultTemplate);
$this->assertStringContainsString('data-app-component="flash-auto-dismiss"', $flashPartial);
$this->assertStringContainsString('data-app-component="nav-history"', $topbarPartial);
$this->assertStringContainsString('data-app-component="tenant-switcher"', $topbarPartial);
$this->assertStringContainsString('data-app-component="theme-controls"', $topbarPartial);
$this->assertStringContainsString('data-app-component="contrast-toggle"', $topbarPartial);
$this->assertStringContainsString('data-app-component="details-aside-toggle"', $topbarPartial);
$this->assertStringContainsString('data-app-component="aside-panels"', $asideIconBar);
$this->assertStringContainsString('data-app-component="sidebar-toggle"', $asidePartial);
$this->assertStringContainsString('data-app-component="global-search"', $asidePartial);
$this->assertStringContainsString('data-app-component="bookmark-panel"', $bookmarkPanelTemplate);
$this->assertStringContainsString('data-app-component="bookmark-save"', $bookmarkDialog);
$this->assertStringContainsString('data-app-component="session-warning"', $sessionWarningDialog);
}
public function testTabsHostsUseDataAppComponentContract(): void
{
$files = [
'modules/addressbook/pages/address-book/view(default).phtml',
'pages/admin/departments/_form.phtml',
'pages/admin/permissions/_form.phtml',
'pages/admin/roles/_form.phtml',
'pages/admin/settings/index(default).phtml',
'pages/admin/stats/index(default).phtml',
'pages/admin/tenants/_form.phtml',
'pages/admin/users/_form.phtml',
];
foreach ($files as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('data-tabs', $content, $file);
$this->assertStringContainsString('data-app-component="tabs"', $content, $file);
}
}
public function testPageEditorUsesPageModuleEntrypointWithPageConfig(): void
{
$pageEditor = $this->readProjectFile('web/js/components/app-page-editor.js');
$pageEditorEntry = $this->readProjectFile('web/js/pages/app-page-editor-entry.js');
$adminPage = $this->readProjectFile('pages/page/index(default).phtml');
$publicPage = $this->readProjectFile('pages/public-page/index(page).phtml');
$this->assertStringContainsString('export function initPageEditor(root = document, config = {})', $pageEditor);
$this->assertStringNotContainsString('const form = document.querySelector', $pageEditor);
$this->assertStringContainsString("readPageConfig('page-editor')", $pageEditorEntry);
$this->assertStringContainsString("assetVersion('js/pages/app-page-editor-entry.js')", $adminPage);
$this->assertStringContainsString("assetVersion('js/pages/app-page-editor-entry.js')", $publicPage);
$this->assertStringNotContainsString("assetVersion('js/components/app-page-editor.js')", $adminPage);
$this->assertStringNotContainsString("assetVersion('js/components/app-page-editor.js')", $publicPage);
$this->assertStringContainsString('id="page-config-page-editor"', $adminPage);
$this->assertStringContainsString('id="page-config-page-editor"', $publicPage);
}
public function testNoCustomWindowGlobalsRemainInWebJs(): void
{
$violations = $this->findPatternMatchesInFiles(
'web/js',
'/window\.(AppSidebar|AppAsidePanels|requestFsLightboxRefresh|__fsLightboxRefreshPending|APP_ASSET_BASE|APP_BASE)\b/',
['js']
);
$this->assertSame([], $violations, 'Custom window globals found in web/js: ' . implode(', ', $violations));
}
public function testHostBoundRuntimeComponentsAreRootStrictWithoutDocumentFallbackQuery(): void
{
$files = [
'web/js/components/app-global-search.js',
'modules/bookmarks/web/js/components/app-bookmark-panel.js',
'web/js/components/app-tabs.js',
];
foreach ($files as $file) {
$content = $this->readProjectFile($file);
$this->assertDoesNotMatchRegularExpression('/\|\|\s*document\.querySelector/', $content, $file);
}
}
public function testDomBindingUtilitiesExposeDestroyLifecycle(): void
{
$activeFilterChips = $this->readProjectFile('web/js/components/app-active-filter-chips.js');
$multiselectCascade = $this->readProjectFile('web/js/components/app-multiselect-cascade.js');
$this->assertStringContainsString('return { render, destroy };', $activeFilterChips);
$this->assertStringContainsString('return {', $multiselectCascade);
$this->assertStringContainsString('destroy: () => {', $multiselectCascade);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class NoBookmarksHardcodingTest extends TestCase
{
public function testCoreTemplatesHaveNoBookmarkSpecificMarkup(): void
{
$files = [
dirname(__DIR__, 2) . '/templates/default.phtml',
dirname(__DIR__, 2) . '/templates/partials/app-topbar.phtml',
dirname(__DIR__, 2) . '/templates/partials/app-main-aside.phtml',
dirname(__DIR__, 2) . '/templates/partials/app-main-aside-icon-bar.phtml',
];
foreach ($files as $file) {
$content = file_get_contents($file);
self::assertIsString($content);
self::assertStringNotContainsString('aside-tab-bookmarks', $content, $file);
self::assertStringNotContainsString('aside-panel-bookmarks', $content, $file);
self::assertStringNotContainsString('data-app-bookmark-dialog', $content, $file);
self::assertStringNotContainsString('bookmarks.nav', $content, $file);
self::assertStringNotContainsString("components.bookmark", $content, $file);
}
}
public function testCoreBookmarkEntrypointsAreRemoved(): void
{
$root = dirname(__DIR__, 2);
self::assertFileDoesNotExist($root . '/templates/partials/app-bookmark-dialog.phtml');
self::assertFileDoesNotExist($root . '/web/js/components/app-bookmark-save.js');
self::assertFileDoesNotExist($root . '/web/js/components/app-bookmark-panel.js');
self::assertFileDoesNotExist($root . '/web/css/components/app-bookmark-form.css');
self::assertDirectoryDoesNotExist($root . '/pages/bookmarks');
}
public function testCoreNoLongerContainsBookmarkServiceDirectory(): void
{
$root = dirname(__DIR__, 2);
self::assertDirectoryDoesNotExist(
$root . '/lib/Service/Bookmark',
'Bookmark service classes should live in modules/bookmarks/lib, not in Core lib/Service'
);
}
public function testCoreNoLongerContainsBookmarkRepositoryFiles(): void
{
$root = dirname(__DIR__, 2);
$repoDir = $root . '/lib/Repository/User';
if (!is_dir($repoDir)) {
self::addToAssertionCount(1);
return;
}
$entries = scandir($repoDir) ?: [];
$bookmarkFiles = array_filter($entries, static fn (string $f): bool => str_starts_with($f, 'Bookmark'));
self::assertSame(
[],
array_values($bookmarkFiles),
'Core lib/Repository/User/ still contains Bookmark files: ' . implode(', ', $bookmarkFiles)
);
}
public function testCoreNoLongerContainsBookmarkUrlNormalizer(): void
{
$root = dirname(__DIR__, 2);
self::assertFileDoesNotExist(
$root . '/lib/Support/BookmarkUrlNormalizer.php',
'BookmarkUrlNormalizer should live in modules/bookmarks/lib, not in Core lib/Support'
);
}
public function testCoreAuthLifecycleHasNoLegacyBookmarkSessionKeyWrites(): void
{
$file = dirname(__DIR__, 2) . '/lib/Service/Auth/AuthSessionTenantContextService.php';
$content = file_get_contents($file);
self::assertIsString($content);
self::assertStringNotContainsString('user_bookmarks', $content);
self::assertStringNotContainsString('module.bookmarks.grouped', $content);
}
}

View File

@@ -1,491 +0,0 @@
<?php
namespace MintyPHP\Tests\Service\Bookmark;
use MintyPHP\Repository\User\BookmarkGroupRepositoryInterface;
use MintyPHP\Repository\User\BookmarkNavigationRepositoryInterface;
use MintyPHP\Repository\User\BookmarkRepositoryInterface;
use MintyPHP\Service\Bookmark\BookmarkService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class BookmarkServiceTest extends TestCase
{
private BookmarkRepositoryInterface&MockObject $bookmarkRepo;
private BookmarkGroupRepositoryInterface&MockObject $groupRepo;
private BookmarkNavigationRepositoryInterface&MockObject $navigationRepo;
private BookmarkService $service;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -1,51 +0,0 @@
<?php
namespace MintyPHP\Tests\Support;
use MintyPHP\Support\BookmarkUrlNormalizer;
use PHPUnit\Framework\TestCase;
class BookmarkUrlNormalizerTest extends TestCase
{
public function testCanonicalizeRelativeNormalizesPathAndQueryOrder(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('/admin//users/?b=2&a=1');
$this->assertSame('admin/users?a=1&b=2', $actual);
}
public function testCanonicalizeRelativeSortsMultiValueQueryKeys(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?roles=2&roles=1&search=max');
$this->assertSame('admin/users?roles=1&roles=2&search=max', $actual);
}
public function testCanonicalizeRelativeRejectsAbsoluteUrl(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('https://example.com/admin/users');
$this->assertSame('', $actual);
}
public function testCanonicalizeRelativeRemovesFragment(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?a=1#section-2');
$this->assertSame('admin/users?a=1', $actual);
}
public function testCanonicalizeRequestUriStripsLocaleBaseAndSortsQuery(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/de/admin/users?b=2&a=1', '/de/');
$this->assertSame('admin/users?a=1&b=2', $actual);
}
public function testCanonicalizeRequestUriHandlesAppBaseAndLocale(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/core/de/address-book?tenants=b&tenants=a', '/core/de/');
$this->assertSame('address-book?tenants=a&tenants=b', $actual);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace MintyPHP\Tests\Unit\Module;
use MintyPHP\Module\Bookmarks\BookmarksAuthorizationPolicy;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
use PHPUnit\Framework\TestCase;
final class BookmarksAuthorizationPolicyTest extends TestCase
{
public function testImplementsAuthorizationPolicyContract(): void
{
$policy = new BookmarksAuthorizationPolicy();
self::assertInstanceOf(AuthorizationPolicyInterface::class, $policy);
}
public function testSupportsBookmarksAbilityOnly(): void
{
$policy = new BookmarksAuthorizationPolicy();
self::assertTrue($policy->supports(BookmarksAuthorizationPolicy::ABILITY_USE));
self::assertFalse($policy->supports('addressbook.view'));
}
public function testAuthorizeAllowsAuthenticatedActors(): void
{
$policy = new BookmarksAuthorizationPolicy();
$decision = $policy->authorize(BookmarksAuthorizationPolicy::ABILITY_USE, [
'actor_user_id' => 7,
]);
self::assertTrue($decision->isAllowed());
}
public function testAuthorizeDeniesMissingActor(): void
{
$policy = new BookmarksAuthorizationPolicy();
$decision = $policy->authorize(BookmarksAuthorizationPolicy::ABILITY_USE, []);
self::assertFalse($decision->isAllowed());
self::assertSame(403, $decision->status());
}
}