// @vitest-environment jsdom // // The admin shell + AUTH GATE. The loader redirects an unauthenticated request // and otherwise returns the shared user. The rendered shell derives its nav // from the api capability map (useCapabilityManifest + deriveEntityAdmins) and // wraps an undo/redo bar (useUndoLog). We mock @voltro/client so those hooks are // deterministic, mock the framework hooks, and import the REAL RedirectError. import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { act, createElement, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { RedirectError } from '@voltro/web' import { I18nProvider } from '@voltro/i18n' import enCatalog from '../../locales/en' const loaderData = vi.fn<() => unknown>() const location = vi.fn<() => string>() vi.mock('@voltro/web', async () => { const actual = await vi.importActual('@voltro/web') return { ...actual, useLoaderData: () => loaderData(), useLocation: () => location() } }) interface ManifestState { manifest: unknown; loading: boolean } const capabilityManifest = vi.fn<() => ManifestState>() const entityAdmins = vi.fn<() => Array<{ table: string }>>() const undoLog = vi.fn(() => ({ canUndo: false, canRedo: false, undoLast: vi.fn(), redoLast: vi.fn(), })) vi.mock('@voltro/client', () => ({ useCapabilityManifest: () => capabilityManifest(), deriveEntityAdmins: () => entityAdmins(), useUndoLog: () => undoLog(), PermissionProvider: ({ children }: { children?: ReactNode }) => children, })) const { default: AdminLayout, loader } = await import('./layout') ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true let container: HTMLDivElement let root: Root // AdminLayout uses /useLocale + , so wrap renders in the // framework's (auto-wired in the running app). const render = (node: ReactNode): void => { container = document.createElement('div') document.body.appendChild(container) act(() => { root = createRoot(container) root.render( createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }), ) }) } beforeEach(() => { loaderData.mockReset() location.mockReset() capabilityManifest.mockReset() entityAdmins.mockReset() undoLog.mockClear() }) afterEach(() => { if (root) act(() => root.unmount()) container?.remove() document.body.innerHTML = '' }) describe('admin layout — loader (auth gate)', () => { test('redirects to /login (remembering the destination) when unauthenticated', async () => { const err = await Promise.resolve(loader({ headers: {} } as never)).catch((e: unknown) => e) expect(err).toBeInstanceOf(RedirectError) expect((err as RedirectError).location).toBe('/login?from=/admin') }) test('returns the shared user for a signed-in request', async () => { const data = await loader({ headers: { cookie: 'demo_session=ok' } } as never) expect(data.user.name).toBe('Demo Admin') }) }) describe('admin layout — render', () => { test('renders a nav item per discovered entity plus the Overview link', () => { loaderData.mockReturnValue({ user: { name: 'Root Admin' } }) location.mockReturnValue('/admin') capabilityManifest.mockReturnValue({ manifest: {}, loading: false }) entityAdmins.mockReturnValue([{ table: 'todos' }, { table: 'projects' }]) render(createElement(AdminLayout, { children: createElement('p', null, 'admin body') })) expect(container.textContent).toContain('Root Admin') const hrefs = Array.from(container.querySelectorAll('a')).map((a) => a.getAttribute('href')) expect(hrefs).toContain('/admin') expect(hrefs).toContain('/admin/todos') expect(hrefs).toContain('/admin/projects') expect(container.querySelector('main')!.textContent).toContain('admin body') }) test('shows the discovering hint before the manifest arrives', () => { loaderData.mockReturnValue({ user: { name: 'Root Admin' } }) location.mockReturnValue('/admin') capabilityManifest.mockReturnValue({ manifest: undefined, loading: true }) entityAdmins.mockReturnValue([]) render(createElement(AdminLayout, { children: null })) expect(container.textContent).toContain('discovering…') }) })