Files
XC_VM/tests/Unit/NavbarProfileMenuTest.php
T
Divarion-D f107e5f769 feat(navbar): make admin profile dropdown registry-driven
The top-right profile dropdown was a hardcoded HTML block in
  admin/header.php, so modules could not add or remove entries and the
  plex/watch "settings" links were baked into core.

  Move the menu into the NavbarRegistry: CoreNavbarProvider now registers
  the core items (edit profile, settings, backups, cache, modules, logout)
  under a reserved 'profile' parent, and header.php renders
  NavbarRegistry::getChildren('profile') adaptively. There is intentionally
  no top-level 'profile' node, so the items never leak into the main
  navigation. Order 100–980 is reserved for module-provided links.

  Extract NavbarRegistry::collapseDividers() to drop separators orphaned by
  permission filtering (leading, trailing, consecutive), and reuse the
  shared _xc_nav_visible()/_xc_nav_label() helpers by defining them once
  near the top of the header. Remove the hardcoded settings_plex /
  settings_watch entries — those now belong to their owning modules.

  Add NavbarProfileMenuTest covering collapseDividers, the core 'profile'
  contract (no top-level leak, edit first / logout last, permission gates),
  and the module-integration contract (reserved order slots, divider
  collapse when folder_watch_settings is absent).
2026-07-29 19:37:43 +03:00

234 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use XcVm\Core\Module\CoreNavbarProvider;
use XcVm\Core\Module\NavbarItem;
use XcVm\Core\Module\NavbarRegistry;
use PHPUnit\Framework\TestCase;
/**
* Profile dropdown — registry contract tests.
*
* The admin profile dropdown (src/Public/Views/admin/header.php) is
* registry-driven: core items are registered by CoreNavbarProvider under the
* reserved 'profile' parent, modules inject their own at order 100–980, and
* the header renders NavbarRegistry::getChildren('profile') after
* NavbarRegistry::collapseDividers() drops separators orphaned by permission
* filtering.
*
* These tests lock down that contract, including the exact keys/orders the
* Plex (1.0.2) and Watch (1.0.5) modules register — those modules live in
* separate repos and cannot be autoloaded here, so their registration is
* mirrored by anonymous providers.
*/
final class NavbarProfileMenuTest extends TestCase {
protected function setUp(): void {
NavbarRegistry::reset();
}
protected function tearDown(): void {
NavbarRegistry::reset();
}
/**
* Visibility filter mirroring header.php's _xc_nav_visible() for flat
* dropdown items: dividers always pass (collapsed later), permissioned
* items pass when the user holds at least one required permission.
*
* @param NavbarItem[] $items
* @param string[] $granted
* @return NavbarItem[]
*/
private function filterVisible(array $items, array $granted): array {
$out = [];
foreach ($items as $item) {
if ($item->divider) {
$out[] = $item;
continue;
}
if (empty($item->permissions) || array_intersect($item->permissions, $granted)) {
$out[] = $item;
}
}
return $out;
}
/** @param NavbarItem[] $items @return string[] */
private function keys(array $items): array {
return array_map(static fn(NavbarItem $i) => $i->key, $items);
}
// ── collapseDividers() ────────────────────────────────────────
public function testCollapseDropsLeadingDivider(): void {
$items = [
(new NavbarItem('a'))->makeDivider(),
(new NavbarItem('b'))->url('x'),
];
$this->assertSame(['b'], $this->keys(NavbarRegistry::collapseDividers($items)));
}
public function testCollapseDropsTrailingDivider(): void {
$items = [
(new NavbarItem('a'))->url('x'),
(new NavbarItem('b'))->makeDivider(),
];
$this->assertSame(['a'], $this->keys(NavbarRegistry::collapseDividers($items)));
}
public function testCollapseSquashesConsecutiveDividers(): void {
$items = [
(new NavbarItem('a'))->url('x'),
(new NavbarItem('d1'))->makeDivider(),
(new NavbarItem('d2'))->makeDivider(),
(new NavbarItem('b'))->url('y'),
];
// Exactly one divider survives, and it is the first of the run.
$this->assertSame(['a', 'd1', 'b'], $this->keys(NavbarRegistry::collapseDividers($items)));
}
public function testCollapseHandlesEmptyAndAllDividers(): void {
$this->assertSame([], NavbarRegistry::collapseDividers([]));
$allDividers = [
(new NavbarItem('d1'))->makeDivider(),
(new NavbarItem('d2'))->makeDivider(),
];
$this->assertSame([], NavbarRegistry::collapseDividers($allDividers));
}
// ── CoreNavbarProvider 'profile' registration ─────────────────
public function testProfileParentDoesNotLeakIntoTopLevel(): void {
CoreNavbarProvider::register();
foreach (NavbarRegistry::getTopLevel() as $item) {
$this->assertNotSame('profile', $item->key, "'profile' must not be a top-level nav item");
$this->assertNotSame('profile', $item->parent);
$this->assertStringStartsNotWith('profile.', $item->key, 'profile children must not appear in the top-level menu');
}
// But the children are retrievable for the dropdown.
$this->assertNotEmpty(NavbarRegistry::getChildren('profile'));
}
public function testProfileEditIsFirstAndLogoutIsLast(): void {
CoreNavbarProvider::register();
$children = NavbarRegistry::getChildren('profile');
$this->assertSame('profile.edit', $children[0]->key);
$this->assertSame('profile.logout', end($children)->key);
}
public function testProfileCorePermissionsMatchLegacyHeader(): void {
CoreNavbarProvider::register();
$byKey = [];
foreach (NavbarRegistry::getChildren('profile') as $item) {
$byKey[$item->key] = $item;
}
// edit_profile and logout are always visible (no permission gate).
$this->assertSame([], $byKey['profile.edit']->permissions);
$this->assertSame([], $byKey['profile.logout']->permissions);
$this->assertSame('edit_profile', $byKey['profile.edit']->url);
$this->assertSame('logout', $byKey['profile.logout']->url);
// Settings/modules gate on 'settings'; backups/cache on 'database'.
$this->assertSame(['settings'], $byKey['profile.settings']->permissions);
$this->assertSame(['settings'], $byKey['profile.modules']->permissions);
$this->assertSame(['database'], $byKey['profile.backups']->permissions);
$this->assertSame(['database'], $byKey['profile.cache']->permissions);
}
public function testCoreOnlyProfileMenuHasSingleDividerBeforeLogout(): void {
CoreNavbarProvider::register();
// Full-permission admin sees every core item.
$visible = $this->filterVisible(NavbarRegistry::getChildren('profile'), ['settings', 'database']);
$rendered = $this->keys(NavbarRegistry::collapseDividers($visible));
$this->assertSame([
'profile.edit',
'profile.settings',
'profile.backups',
'profile.cache',
'profile.modules',
'profile.logout_divider',
'profile.logout',
], $rendered);
}
public function testBasicUserSeesOnlyEditDividerLogout(): void {
CoreNavbarProvider::register();
// No advanced permissions granted.
$visible = $this->filterVisible(NavbarRegistry::getChildren('profile'), []);
$rendered = $this->keys(NavbarRegistry::collapseDividers($visible));
$this->assertSame(['profile.edit', 'profile.logout_divider', 'profile.logout'], $rendered);
}
// ── Module integration contract (Plex 1.0.2 + Watch 1.0.5) ────
/**
* Register the profile items exactly as WatchModule::registerNavbar() and
* PlexModule::registerNavbar() do, so this test fails if those modules
* drift from the reserved order slots or the 'profile' parent.
*/
private function registerModuleProfileItems(): void {
// WatchModule 1.0.5 — owns the folder-settings divider.
NavbarRegistry::add((new NavbarItem('profile.folder_divider'))
->parent('profile')->makeDivider()->order(100));
NavbarRegistry::add((new NavbarItem('profile.watch_settings'))
->parent('profile')->url('settings_watch')
->label('watch_settings')->permissions(['folder_watch_settings'])->order(110));
// PlexModule 1.0.2 — depends on watch, sits after it.
NavbarRegistry::add((new NavbarItem('profile.plex_settings'))
->parent('profile')->url('settings_plex')
->label('plex_settings')->permissions(['folder_watch_settings'])->order(120));
}
public function testModuleItemsSitBetweenCoreSettingsAndLogout(): void {
CoreNavbarProvider::register();
$this->registerModuleProfileItems();
$visible = $this->filterVisible(
NavbarRegistry::getChildren('profile'),
['settings', 'database', 'folder_watch_settings']
);
$rendered = $this->keys(NavbarRegistry::collapseDividers($visible));
$this->assertSame([
'profile.edit',
'profile.settings',
'profile.backups',
'profile.cache',
'profile.modules',
'profile.folder_divider',
'profile.watch_settings',
'profile.plex_settings',
'profile.logout_divider',
'profile.logout',
], $rendered);
}
public function testHiddenModuleItemsCollapseTheFolderDivider(): void {
CoreNavbarProvider::register();
$this->registerModuleProfileItems();
// User can see core settings but NOT folder_watch_settings — the
// module divider must not survive as an orphan next to logout's.
$visible = $this->filterVisible(NavbarRegistry::getChildren('profile'), ['settings', 'database']);
$rendered = $this->keys(NavbarRegistry::collapseDividers($visible));
$this->assertSame([
'profile.edit',
'profile.settings',
'profile.backups',
'profile.cache',
'profile.modules',
'profile.folder_divider',
'profile.logout',
], $rendered);
// Exactly one divider in the final menu.
$dividers = array_filter($rendered, static fn($k) => str_contains($k, 'divider'));
$this->assertCount(1, $dividers);
}
}