diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 206f063d5..20c6c1a5c 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,7 +1,7 @@ -// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011 +// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025 import { render, screen, waitFor, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; +import { delay, http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { useSettingsStore } from '../../store/settingsStore'; @@ -21,6 +21,45 @@ function buildAddon(overrides = {}) { }; } +function addonsRoute(addons: ReturnType[]) { + return http.get('/api/admin/addons', () => HttpResponse.json({ addons })); +} + +function llmAddon(config: Record = {}) { + return buildAddon({ + id: 'llm_parsing', + name: 'AI Parsing', + description: 'Extract bookings from files', + icon: 'Sparkles', + type: 'integration', + enabled: true, + config, + }); +} + +function modelsRoute(names: string[], seen?: (string | null)[]) { + return http.get('/api/admin/llm/local/models', ({ request }) => { + seen?.push(new URL(request.url).searchParams.get('baseUrl')); + return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) }); + }); +} + +/** The pill toggle of a top-level addon row. */ +function addonToggle(name: string): HTMLElement { + const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement; + return within(row).getByRole('button'); +} + +/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */ +function subToggle(label: string): HTMLElement { + const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement; + return within(row).getByRole('button'); +} + +function isOn(toggle: HTMLElement): boolean { + return toggle.style.background === 'var(--text-primary)'; +} + beforeAll(() => { Object.defineProperty(window, 'matchMedia', { writable: true, @@ -229,4 +268,330 @@ describe('AddonManager', () => { expect(() => render()).not.toThrow(); await screen.findByText('Mystery Addon'); }); + + it('FE-ADMIN-ADDON-012: a failing load toasts the addon error and shows the empty state', async () => { + server.use(http.get('/api/admin/addons', () => HttpResponse.error())); + render(<>); + + await screen.findByText('Failed to update addon'); + expect(screen.getByText('No addons available')).toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => { + seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } }); + render(); + + await screen.findByText('No addons available'); + expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg'); + }); + + it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }), + buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }), + buildAddon({ id: 'todo', name: 'Todo List' }), + ])); + render(); + + await screen.findByText('Todo List'); + expect(screen.queryByText('Memories')).not.toBeInTheDocument(); + expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-015: provider sub-rows carry their vendor icons and toggle state', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }), + buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }), + buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }), + ])); + render(); + + await screen.findByText('Immich'); + // immich and synologyphotos ship a vendor glyph, unsplash does not + const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement; + expect(immichRow.querySelector('svg')).toBeInTheDocument(); + const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement; + expect(synologyRow.querySelector('svg')).toBeInTheDocument(); + const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement; + expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument(); + + expect(isOn(subToggle('Immich'))).toBe(true); + expect(isOn(subToggle('Unsplash'))).toBe(false); + }); + + it('FE-ADMIN-ADDON-016: toggling a photo provider persists it and refreshes the global addons', async () => { + const user = userEvent.setup(); + let body: unknown = null; + server.use( + addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }), + ]), + http.put('/api/admin/addons/immich', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Immich'); + + await user.click(subToggle('Immich')); + + await waitFor(() => expect(body).toEqual({ enabled: true })); + await screen.findByText('Addon updated'); + expect(isOn(subToggle('Immich'))).toBe(true); + }); + + it('FE-ADMIN-ADDON-017: a failing photo-provider toggle rolls the sub-row back', async () => { + const user = userEvent.setup(); + server.use( + addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), + buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: true }), + ]), + http.put('/api/admin/addons/unsplash', () => HttpResponse.error()), + ); + render(<>); + await screen.findByText('Unsplash'); + + await user.click(subToggle('Unsplash')); + + await screen.findByText('Failed to update addon'); + await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true)); + }); + + it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => { + const user = userEvent.setup(); + const onToggleCollabFeature = vi.fn(); + server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); + render( + , + ); + + await screen.findByText('Chat'); + expect(screen.getByText('Notes')).toBeInTheDocument(); + expect(screen.getByText('Polls')).toBeInTheDocument(); + expect(screen.getByText("What's Next")).toBeInTheDocument(); + expect(isOn(subToggle('Chat'))).toBe(true); + expect(isOn(subToggle('Notes'))).toBe(false); + + await user.click(subToggle('Polls')); + expect(onToggleCollabFeature).toHaveBeenCalledWith('polls'); + }); + + it('FE-ADMIN-ADDON-019: collab sub-features stay hidden without the handler props', async () => { + server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); + render(); + + await screen.findByText('Collab'); + expect(screen.queryByText('Polls')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => { + server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }])); + render(); + + await screen.findByText('AI Parsing'); + expect(screen.getByText('Extract bookings from files')).toBeInTheDocument(); + expect(screen.queryByText('Connection')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => { + const user = userEvent.setup(); + const urls: (string | null)[] = []; + server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls)); + render(); + + await screen.findByText('Installed on the server'); + await screen.findByRole('button', { name: 'llama3:8b' }); + expect(urls[0]).toBe('http://localhost:11434/v1'); + + await user.click(screen.getByRole('button', { name: 'llama3:8b' })); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b'); + + // qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull" + await user.click(screen.getByRole('button', { name: 'Use' })); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b'); + expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled(); + }); + + it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => { + const user = userEvent.setup(); + let calls = 0; + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + http.get('/api/admin/llm/local/models', () => { + calls += 1; + return calls === 1 + ? HttpResponse.json({ error: 'down' }, { status: 500 }) + : HttpResponse.json({ models: [] }); + }), + ); + render(); + + await screen.findByText(/Request failed with status code 500/); + + await user.click(screen.getByRole('button', { name: 'Refresh' })); + await screen.findByText('No models installed yet — pull one below.'); + expect(calls).toBe(2); + }); + + it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => { + const user = userEvent.setup(); + const urls: (string | null)[] = []; + server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls)); + render(); + + await screen.findByText('Installed on the server'); + expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); + + // A hand-typed base URL is used for the next lookup on blur + await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1'); + await user.tab(); + await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1')); + + await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ })); + await user.click(screen.getByRole('button', { name: 'OpenAI' })); + expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument(); + expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'OpenAI' })); + await user.click(screen.getByRole('button', { name: 'Anthropic' })); + expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument(); + expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => { + const user = userEvent.setup(); + let pulled: unknown = null; + let modelCalls = 0; + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + http.get('/api/admin/llm/local/models', () => { + modelCalls += 1; + return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] }); + }), + http.post('/api/admin/llm/local/pull', async ({ request }) => { + pulled = await request.json(); + await delay(150); + return new HttpResponse( + '{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n', + { headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }), + ); + render(<>); + + await screen.findByText('No models installed yet — pull one below.'); + await user.click(screen.getByRole('button', { name: 'Pull' })); + + await screen.findByText('Pulling…'); + expect(screen.getByText('starting…')).toBeInTheDocument(); + + await screen.findByText('Model pulled'); + expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' }); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled()); + }); + + it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => { + const user = userEvent.setup(); + const bodies: unknown[] = []; + server.use( + addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]), + modelsRoute([]), + http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })), + http.put('/api/admin/addons/llm_parsing', async ({ request }) => { + bodies.push(await request.json()); + return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error(); + }), + ); + render(<>); + + await screen.findByText('No models installed yet — pull one below.'); + await user.click(screen.getByRole('button', { name: 'Pull' })); + await screen.findByText('no disk space'); + expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('Saved'); + expect(bodies[0]).toEqual({ + config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true }, + }); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('Failed to save'); + }); + + it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => { + const user = userEvent.setup(); + const bodies: unknown[] = []; + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + modelsRoute([]), + http.put('/api/admin/addons/llm_parsing', async ({ request }) => { + bodies.push(await request.json()); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + + await screen.findByText('Installed on the server'); + expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b '); + await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live'); + + await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ })); + await user.click(screen.getByRole('button', { name: 'OpenAI' })); + expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live'); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('Saved'); + // The model is trimmed before it is stored, the key is sent verbatim + expect(bodies[0]).toEqual({ + config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', multimodal: false }, + }); + }); + + it('FE-ADMIN-ADDON-027: an error frame in the pull stream is tolerated and the row returns to idle', async () => { + const user = userEvent.setup(); + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + modelsRoute([]), + http.post('/api/admin/llm/local/pull', () => new HttpResponse( + '{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', + { headers: { 'Content-Type': 'application/x-ndjson' } }, + )), + ); + render(<>); + + await screen.findByText('No models installed yet — pull one below.'); + await user.click(screen.getByRole('button', { name: 'Pull' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled()); + expect(screen.queryByText('Pulling…')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => { + const user = userEvent.setup(); + const urls: (string | null)[] = []; + server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls)); + render(); + + await screen.findByText('Connection'); + expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1'); + await user.tab(); + + await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument()); + expect(urls).toHaveLength(0); + }); }); diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 8c1884818..fa6ad9615 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -1,8 +1,10 @@ import { http, HttpResponse } from 'msw' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import userEvent from '@testing-library/user-event' import { server } from '../../../tests/helpers/msw/server' -import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render' +import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render' import { resetAllStores } from '../../../tests/helpers/store' +import { ToastContainer } from '../shared/Toast' import AdminPluginsPanel from './AdminPluginsPanel' /** @@ -482,3 +484,825 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => { expect(await screen.findByText(/needs trek >=3\.2\.0 <4\.0\.0/i)).toBeInTheDocument() }) }) + +/** + * The rest of the panel: load states, the toolbar, sideloading, the row actions behind the + * ⋯ menu, and the dialog each 409 routes to. + */ +function panelWith( + plugins: Record[], + opts: { enabled?: boolean; devLink?: boolean; registry?: Record[] } = {}, +) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ + enabled: opts.enabled ?? true, devLink: opts.devLink ?? false, plugins, + })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json(opts.registry ?? [])), + ) +} + +function withToast() { + return render(<>) +} + +/** The Discover tab is a role="tab"; the empty state offers a plain button with the same label. */ +async function clickDiscover() { + const tabs = await screen.findAllByRole('tab', { name: /^discover/i }) + fireEvent.click(tabs[0]) +} + +/** The panel root, which owns the drag-and-drop handlers. */ +function panelRoot(container: HTMLElement): HTMLElement { + return (container.querySelector('input[type="file"]') as HTMLElement).parentElement as HTMLElement +} + +/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */ +async function pickFilter(trigger: RegExp, option: RegExp) { + fireEvent.click(await screen.findByTitle(trigger)) + fireEvent.click(await screen.findByRole('button', { name: option })) +} + +/** The enable toggle of a named installed row. */ +function rowToggle(name: string): HTMLElement { + const row = screen.getByText(name).closest('.group') as HTMLElement + return within(row).getByRole('button', { name: 'Enable plugin' }) +} + +describe('AdminPluginsPanel — load states and toolbar', () => { + it('FE-COMP-PLUGINS-PANEL-001: a failing plugin list shows the load error instead of an empty list', async () => { + server.use(http.get('*/api/admin/plugins', () => HttpResponse.error())) + render() + + expect(await screen.findByText('Could not load plugins.')).toBeInTheDocument() + expect(screen.queryByPlaceholderText('Search plugins…')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-002: a disabled runtime replaces the whole body with the notice', async () => { + panelWith([plugin()], { enabled: false }) + render() + + expect(await screen.findByText('Plugins are disabled')).toBeInTheDocument() + expect(screen.queryByText('Gotify')).not.toBeInTheDocument() + expect(screen.queryByText('Runtime on')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-003: with nothing installed the empty state jumps to Discover', async () => { + panelWith([]) + render() + + await screen.findByText('No plugins installed yet.') + expect(screen.getByText('Runtime on')).toBeInTheDocument() + fireEvent.click(screen.getAllByRole('button', { name: /^discover/i }).pop()!) + + expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-004: the search box filters the installed list and says when nothing matches', async () => { + panelWith([plugin({ id: 'a', name: 'Gotify' }), plugin({ id: 'b', name: 'Ntfy', description: 'Push too' })]) + render() + await screen.findByText('Gotify') + + fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'ntfy' } }) + expect(screen.queryByText('Gotify')).not.toBeInTheDocument() + expect(screen.getByText('Ntfy')).toBeInTheDocument() + + fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'zzz' } }) + expect(screen.getByText('No installed plugins match your search.')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-005: the type filter narrows the list and marks itself active', async () => { + panelWith([ + plugin({ id: 'a', name: 'Gotify', type: 'integration' }), + plugin({ id: 'b', name: 'Countdown', type: 'widget' }), + ]) + render() + await screen.findByText('Gotify') + + await pickFilter(/^Type: All types$/, /^Widget$/) + + expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument() + expect(screen.getByText('Countdown')).toBeInTheDocument() + expect(screen.queryByText('Gotify')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-006: the status filter separates off, error and update-available rows', async () => { + panelWith([ + plugin({ id: 'trek-gotify', name: 'OnOne', enabled: 1, status: 'active', version: '1.0.0', source_repo: 'acme/gotify' }), + plugin({ id: 'off-one', name: 'OffOne', enabled: 0, status: 'inactive' }), + plugin({ id: 'bad-one', name: 'BadOne', enabled: 1, status: 'error', last_error: 'boom' }), + ], { registry: [registryEntry({ latest: '2.0.0' })] }) + render() + await screen.findByText('OnOne') + + await pickFilter(/^Status: All$/, /^Off$/) + expect(await screen.findByText('OffOne')).toBeInTheDocument() + expect(screen.queryByText('OnOne')).not.toBeInTheDocument() + + await pickFilter(/^Status: Off$/, /^Error$/) + expect(await screen.findByText('BadOne')).toBeInTheDocument() + expect(screen.getByText('boom')).toBeInTheDocument() + + await pickFilter(/^Status: Error$/, /^Update available$/) + expect(await screen.findByText('OnOne')).toBeInTheDocument() + expect(screen.queryByText('BadOne')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-007: an installed-only sort key snaps back to Name on the Discover tab', async () => { + panelWith([plugin()], { registry: [registryEntry()] }) + render() + await screen.findByText('Gotify') + + await pickFilter(/^Sort: Name$/, /^Updates first$/) + expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument() + + await clickDiscover() + await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument()) + }) + + it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => { + const refreshFlags: (string | null)[] = [] + panelWith([plugin()]) + server.use( + http.post('*/api/admin/plugins/rescan', () => HttpResponse.json({ ok: true })), + http.get('*/api/admin/plugins/registry', ({ request }) => { + refreshFlags.push(new URL(request.url).searchParams.get('refresh')) + return HttpResponse.json([registryEntry({ latest: '2.0.0' })]) + }), + ) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(screen.getAllByTitle('Rescan')[0]) + + expect(await screen.findByText('Rescanned the plugins folder')).toBeInTheDocument() + expect(refreshFlags).toContain('1') + }) +}) + +describe('AdminPluginsPanel — sideloading', () => { + const zip = () => new File(['zip'], 'plugin.zip', { type: 'application/zip' }) + + it('FE-COMP-PLUGINS-PANEL-009: the toolbar button opens the hidden picker and uploads the pick', async () => { + panelWith([plugin()]) + server.use(http.post('*/api/admin/plugins/upload', () => HttpResponse.json({ id: 'trek-new' }))) + const { container } = withToast() + await screen.findByText('Gotify') + + const input = container.querySelector('input[type="file"]') as HTMLInputElement + const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {}) + fireEvent.click(screen.getAllByTitle('Upload plugin')[0]) + expect(clickSpy).toHaveBeenCalled() + clickSpy.mockRestore() + + await userEvent.upload(input, zip()) + expect(await screen.findByText(/trek-new.*uploaded/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-010: a rejected upload surfaces the server message', async () => { + panelWith([plugin()]) + server.use(http.post('*/api/admin/plugins/upload', () => HttpResponse.json({ error: 'not a plugin archive' }, { status: 400 }))) + const { container } = withToast() + await screen.findByText('Gotify') + + await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, zip()) + + expect(await screen.findByText('not a plugin archive')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-011: dragging a file over the panel offers a drop target and installs it', async () => { + let uploaded = false + panelWith([plugin()]) + server.use(http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'trek-dropped' }) })) + const { container } = withToast() + await screen.findByText('Gotify') + + const panel = panelRoot(container) + const dataTransfer = { types: ['Files'], files: [zip()] } + fireEvent.dragEnter(panel, { dataTransfer }) + expect(await screen.findByText('Drop a plugin .zip to install')).toBeInTheDocument() + + fireEvent.dragLeave(panel, { dataTransfer }) + await waitFor(() => expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument()) + + fireEvent.dragEnter(panel, { dataTransfer }) + fireEvent.dragOver(panel, { dataTransfer }) + fireEvent.drop(panel, { dataTransfer }) + + await waitFor(() => expect(uploaded).toBe(true)) + expect(await screen.findByText(/trek-dropped.*uploaded/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-012: a drag that carries no files is ignored', async () => { + panelWith([plugin()]) + const { container } = render() + await screen.findByText('Gotify') + + fireEvent.dragEnter(panelRoot(container), { dataTransfer: { types: ['text/plain'], files: [] } }) + + expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-013: the dev-link form registers a local path and reports failures', async () => { + const paths: string[] = [] + panelWith([plugin()], { devLink: true }) + server.use(http.post('*/api/admin/plugins/link', async ({ request }) => { + const body = await request.json() as { path: string } + paths.push(body.path) + return paths.length === 1 + ? HttpResponse.json({ id: 'trek-local' }) + : HttpResponse.json({ error: 'no manifest there' }, { status: 400 }) + })) + withToast() + const input = await screen.findByPlaceholderText('/absolute/path/to/your/plugin') + + expect(screen.getByRole('button', { name: /^link$/i })).toBeDisabled() + + fireEvent.change(input, { target: { value: '/srv/plugins/mine' } }) + fireEvent.click(screen.getByRole('button', { name: /^link$/i })) + expect(await screen.findByText(/linked trek-local/i)).toBeInTheDocument() + expect(paths).toEqual(['/srv/plugins/mine']) + + fireEvent.change(screen.getByPlaceholderText('/absolute/path/to/your/plugin'), { target: { value: '/nope' } }) + fireEvent.click(screen.getByRole('button', { name: /^link$/i })) + expect(await screen.findByText('no manifest there')).toBeInTheDocument() + }) +}) + +describe('AdminPluginsPanel — row actions', () => { + async function openRowMenu(p: Record = plugin()) { + panelWith([p]) + withToast() + fireEvent.click(await screen.findByTestId(`plugin-row-menu-btn-${p.id}`)) + } + + /** Every modal in the panel closes by clicking its backdrop. */ + function closeModal() { + fireEvent.click(document.querySelector('.fixed.inset-0.z-50') as HTMLElement) + } + + it('FE-COMP-PLUGINS-PANEL-014: the error log lists what the runtime recorded', async () => { + server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => + HttpResponse.json({ errors: [{ ts: '2026-01-01T00:00:00Z', level: 'error', message: 'connect ECONNREFUSED' }] }))) + await openRowMenu() + + fireEvent.click(screen.getByText('View error log')) + expect(await screen.findByText('connect ECONNREFUSED')).toBeInTheDocument() + expect(screen.getByText('error')).toBeInTheDocument() + + closeModal() + await waitFor(() => expect(screen.queryByText('connect ECONNREFUSED')).not.toBeInTheDocument()) + }) + + it('FE-COMP-PLUGINS-PANEL-015: an unreachable error log still opens, empty', async () => { + server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => HttpResponse.error())) + await openRowMenu() + + fireEvent.click(screen.getByText('View error log')) + + expect(await screen.findByText('No errors logged.')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-016: allowed hosts can be added and removed, and a rejected save says why', async () => { + let hosts: string[] = [] + let puts = 0 + server.use( + http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.json({ supported: true, hosts })), + http.put('*/api/admin/plugins/trek-gotify/egress-hosts', async ({ request }) => { + puts += 1 + const body = await request.json() as { hosts: string[] } + if (puts === 3) return HttpResponse.json({ error: 'not a valid host' }, { status: 400 }) + hosts = body.hosts + return HttpResponse.json({ hosts }) + }), + ) + await openRowMenu() + + fireEvent.click(screen.getByText('Allowed hosts')) + expect(await screen.findByText('No hosts added yet.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^add$/i })).toBeDisabled() + + fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'gotify.lan' } }) + fireEvent.click(screen.getByRole('button', { name: /^add$/i })) + expect(await screen.findByText('gotify.lan')).toBeInTheDocument() + + fireEvent.click(screen.getAllByLabelText('Delete')[0]) + await waitFor(() => expect(screen.getByText('No hosts added yet.')).toBeInTheDocument()) + + fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'bad host' } }) + fireEvent.click(screen.getByRole('button', { name: /^add$/i })) + expect(await screen.findByText('not a valid host')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-017: a plugin whose hosts are fixed in its manifest says the list is unsupported', async () => { + server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.error())) + await openRowMenu() + + fireEvent.click(screen.getByText('Allowed hosts')) + + expect(await screen.findByText(/does not use operator-supplied hosts/i)).toBeInTheDocument() + expect(screen.queryByPlaceholderText('gotify.example.com')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-018: Delete asks first, then uninstalls with its data', async () => { + let body: unknown = null + server.use(http.post('*/api/admin/plugins/trek-gotify/uninstall', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ ok: true }) + })) + await openRowMenu() + + fireEvent.click(screen.getByText('Delete')) + expect(await screen.findByText('Uninstall plugin?')).toBeInTheDocument() + + fireEvent.click(screen.getAllByRole('button', { name: /^delete$/i }).pop()!) + + expect(await screen.findByText('Plugin uninstalled')).toBeInTheDocument() + expect(body).toEqual({ deleteData: true }) + }) + + it('FE-COMP-PLUGINS-PANEL-019: Restart cycles the plugin off and on again', async () => { + const seen: string[] = [] + server.use( + http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { seen.push('deactivate'); return HttpResponse.json({ ok: true }) }), + http.post('*/api/admin/plugins/trek-gotify/activate', () => { seen.push('activate'); return HttpResponse.json({ ok: true }) }), + ) + await openRowMenu(plugin({ enabled: 1, status: 'active' })) + + fireEvent.click(screen.getByText('Restart')) + + expect(await screen.findByText('Plugin restarted')).toBeInTheDocument() + expect(seen).toEqual(['deactivate', 'activate']) + }) + + it('FE-COMP-PLUGINS-PANEL-020: a disabled plugin offers no Restart', async () => { + await openRowMenu(plugin({ enabled: 0, status: 'inactive' })) + + expect(screen.getByText('View error log')).toBeInTheDocument() + expect(screen.queryByText('Restart')).not.toBeInTheDocument() + }) +}) + +describe('AdminPluginsPanel — enabling a plugin', () => { + it('FE-COMP-PLUGINS-PANEL-021: turning a plugin off deactivates it', async () => { + let called = false + panelWith([plugin({ enabled: 1, status: 'active' })]) + server.use(http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { called = true; return HttpResponse.json({ ok: true }) })) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + + expect(await screen.findByText('Plugin deactivated')).toBeInTheDocument() + expect(called).toBe(true) + }) + + it('FE-COMP-PLUGINS-PANEL-022: turning one on also reports the dependencies it enabled first', async () => { + panelWith([ + plugin({ enabled: 0, status: 'inactive', dependencies: { pluginDependencies: [{ id: 'trek-core', version: '^1.0.0' }] } }), + plugin({ id: 'trek-core', name: 'Core', enabled: 0, status: 'inactive' }), + ]) + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => HttpResponse.json({ ok: true }))) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + + expect(await screen.findByText('Plugin activated')).toBeInTheDocument() + expect(await screen.findByText(/enabled required plugin\(s\) first: core/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-023: a disabled required addon is reported as a toast, not a dialog', async () => { + panelWith([plugin({ enabled: 0, status: 'inactive' })]) + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ code: 'ADDON_DISABLED', addons: ['journey'] }, { status: 409 }))) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + + expect(await screen.findByText(/enable the required addon\(s\) first: journey/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-024: any other activation failure surfaces the server message', async () => { + panelWith([plugin({ enabled: 0, status: 'inactive' })]) + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ code: 'DEPENDENCY_CYCLE', error: 'circular dependency' }, { status: 409 }))) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + + expect(await screen.findByText('circular dependency')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-025: a missing dependency opens the resolve dialog and one click fixes it', async () => { + let activates = 0 + let installed: unknown = null + panelWith([plugin({ enabled: 0, status: 'inactive' })]) + server.use( + http.post('*/api/admin/plugins/trek-gotify/activate', () => { + activates += 1 + return activates === 1 + ? HttpResponse.json({ + code: 'DEPENDENCY_MISSING', + missing: [{ id: 'trek-core', version: '^1.0.0' }], + versionMismatch: [{ id: 'trek-old', wanted: '^2.0.0', installed: '1.0.0' }], + }, { status: 409 }) + : HttpResponse.json({ ok: true }) + }), + http.post('*/api/admin/plugins/install', async ({ request }) => { + installed = await request.json() + return HttpResponse.json({ installed: ['trek-core'], requiredAddons: ['journey'] }) + }), + ) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + expect(await screen.findByText('Missing dependencies')).toBeInTheDocument() + expect(screen.getByText('Requires ^1.0.0')).toBeInTheDocument() + expect(screen.getByText('Needs ^2.0.0 — 1.0.0 is installed')).toBeInTheDocument() + expect(screen.getByText(/downloads the latest compatible version/i)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /^download$/i })) + + expect(await screen.findByText('Downloaded trek-core')).toBeInTheDocument() + expect(installed).toEqual({ id: 'trek-core', constraint: '^1.0.0', withDependencies: true }) + expect(await screen.findByText(/enable the required addon\(s\) first: journey/i)).toBeInTheDocument() + await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument()) + }) + + it('FE-COMP-PLUGINS-PANEL-026: the resolve dialog can be dismissed without downloading anything', async () => { + let installs = 0 + panelWith([plugin({ enabled: 0, status: 'inactive' })]) + server.use( + http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-core', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })), + http.post('*/api/admin/plugins/install', () => { installs += 1; return HttpResponse.json({}) }), + ) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + fireEvent.click(await screen.findByRole('button', { name: /^cancel$/i })) + + await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument()) + expect(installs).toBe(0) + }) + + it('FE-COMP-PLUGINS-PANEL-027: widened permissions route to the consent dialog, which can be deferred', async () => { + panelWith([plugin({ enabled: 0, status: 'inactive', source_repo: 'acme/gotify', signed: false })]) + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips', 'weird:perm'], newEgress: ['api.acme.io'] }, { status: 409 }))) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + + expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument() + expect(screen.getByText('Read trips the acting user can access')).toBeInTheDocument() + // An unknown permission is still shown — as its raw code + expect(screen.getByText('weird:perm')).toBeInTheDocument() + expect(screen.getByText('api.acme.io')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /keep off for now/i })) + expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-028: approving consent re-activates with the consent flag', async () => { + const bodies: unknown[] = [] + panelWith([plugin({ enabled: 0, status: 'inactive' })]) + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', async ({ request }) => { + bodies.push(await request.json()) + return bodies.length === 1 + ? HttpResponse.json({ code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] }, { status: 409 }) + : HttpResponse.json({ ok: true }) + })) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + fireEvent.click(await screen.findByRole('button', { name: /approve & turn on/i })) + + expect(await screen.findByText('Plugin updated')).toBeInTheDocument() + expect(bodies[1]).toEqual({ consent: true }) + }) +}) + +describe('AdminPluginsPanel — updates', () => { + const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false }) + + it('FE-COMP-PLUGINS-PANEL-029: the update banner updates every outdated plugin at once', async () => { + const updated: string[] = [] + panelWith( + [outdated, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })], + { registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] }, + ) + server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => { + updated.push(String(params.id)) + return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }) + })) + withToast() + + expect(await screen.findByText('2 updates available for your plugins.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /update all/i })) + + await waitFor(() => expect([...updated].sort()).toEqual(['trek-gotify', 'trek-ntfy'])) + expect((await screen.findAllByText('Plugin updated')).length).toBeGreaterThan(0) + }) + + it('FE-COMP-PLUGINS-PANEL-030: an update that widens permissions queues the consent dialog', async () => { + panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0', signed: false })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/update', () => + HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:users'], newEgress: [] }))) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /update → v2\.0\.0/i })) + + expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument() + expect(screen.getByText(/read basic profile info/i)).toBeInTheDocument() + // The registry says the new code is unsigned — say so where the rights get widened + expect(screen.getByText(/nothing ties this version to its author/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-031: a failed update that is not a signature refusal is a plain toast', async () => { + panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/update', () => + HttpResponse.json({ error: 'registry unreachable' }, { status: 502 }))) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /update → v2\.0\.0/i })) + + expect(await screen.findByText('registry unreachable')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-032: a re-trusted update that widens rights still asks for consent', async () => { + panelWith([plugin({ + source_repo: 'acme/gotify', version: '1.0.0', keyFingerprint: 'OLDAAAAA…BBBBBBBB', operatorEgress: false, + updateBlock: { code: 'SIGNATURE_KEY_CHANGED', version: '2.0.0', detail: 'key rotated' }, + })], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () => + HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: ['api.acme.io'] }))) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /^review$/i })) + fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i })) + + expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument() + expect(screen.getByText('api.acme.io')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-033: a failed re-trust surfaces the server message', async () => { + panelWith([plugin({ + source_repo: 'acme/gotify', version: '1.0.0', keyFingerprint: 'OLDAAAAA…BBBBBBBB', operatorEgress: false, + updateBlock: { code: 'SIGNATURE_KEY_CHANGED', version: '2.0.0', detail: 'key rotated' }, + })], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () => + HttpResponse.json({ error: 'key does not match' }, { status: 400 }))) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /^review$/i })) + fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i })) + + expect(await screen.findByText('key does not match')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-034a: a stable release counts as newer than the prerelease of the same version', async () => { + panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0-beta.1', operatorEgress: false })], { + registry: [registryEntry({ latest: '2.0.0' })], + }) + render() + + expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-034: a block with no recorded version stands while the registry is silent', async () => { + panelWith([plugin({ + source_repo: 'acme/gotify', operatorEgress: false, + updateBlock: { code: 'SIGNATURE_INVALID', version: null, detail: 'digest mismatch' }, + })], { registry: [] }) + render() + + expect(await screen.findByText(/update blocked — digest mismatch/i)).toBeInTheDocument() + }) +}) + +describe('AdminPluginsPanel — capability and dependency chips', () => { + it('FE-COMP-PLUGINS-PANEL-035: a permission-rich plugin renders one chip per capability', async () => { + panelWith([plugin({ + operatorEgress: false, + permissions: JSON.stringify([ + 'db:read:trips', 'db:read:users', 'db:write:costs', 'db:read:packing', 'db:read:files', + 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:meta', + 'ws:broadcast:trip', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', + 'hook:trip-warning-provider', 'hook:map-layer-provider', 'hook:route-provider', + 'hook:day-schedule-provider', 'geolocation:read', 'events:subscribe', 'http:outbound:api.acme.io', + ]), + capabilities: JSON.stringify({ widget: { slot: 'hero' }, tripPage: { replaces: ['places'] } }), + })]) + render() + await screen.findByText('Gotify') + + for (const label of [ + 'Reads your trips', 'Reads basic profiles', 'Adds costs', 'Reads packing lists', 'Reads trip files', + 'Edits places', 'Edits days', 'Edits itinerary', 'Edits trips', 'Adds metadata', + 'Boarding-pass widget', 'Replaces planner tabs', 'Real-time updates', 'Provides photos', + 'Provides calendar events', 'Enriches places', 'Flags issues', 'Draws on the map', 'Offers routing', + 'Adds plan times', 'Reads your position', 'Reacts to activity', 'api.acme.io', + ]) { + expect(screen.getByText(label)).toBeInTheDocument() + } + }) + + it('FE-COMP-PLUGINS-PANEL-036: read-only costs and a slotted widget each get their own wording', async () => { + panelWith([plugin({ + operatorEgress: false, + permissions: JSON.stringify(['db:read:costs']), + capabilities: JSON.stringify({ widget: { slot: 'place-detail' } }), + })]) + render() + await screen.findByText('Gotify') + + expect(screen.getByText('Reads your costs')).toBeInTheDocument() + expect(screen.getByText('Place detail')).toBeInTheDocument() + expect(screen.queryByText('Adds costs')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-037: an unparseable capabilities blob degrades to the plain widget chip', async () => { + panelWith([plugin({ operatorEgress: false, permissions: 'not json', capabilities: JSON.stringify({ widget: {} }) })]) + render() + await screen.findByText('Gotify') + + expect(screen.getByText('Dashboard widget')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-038: dependency chips flag the addon and plugin the row is waiting on', async () => { + panelWith([plugin({ + operatorEgress: false, + dependencies: { requiredAddons: ['journey'], pluginDependencies: [{ id: 'trek-core', version: '^1.0.0' }] }, + dependencyIssues: { disabledAddons: ['journey'], missing: [{ id: 'trek-core', version: '^1.0.0' }], versionMismatch: [] }, + })]) + render() + + expect(await screen.findByText('Requires journey')).toBeInTheDocument() + expect(screen.getByText('Needs trek-core ^1.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-039: a plugin that names no TREK range says so', async () => { + panelWith([plugin({ operatorEgress: false, dependencyStatus: 'hostIncompatible' })]) + render() + + expect(await screen.findByText('Does not say which TREK versions it supports')).toBeInTheDocument() + }) +}) + +describe('AdminPluginsPanel — Discover cards and the detail modal', () => { + function discoverWith(entry: Record, detail?: Record) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry(entry)])), + http.get('*/api/admin/plugins/registry/trek-gotify', () => + detail ? HttpResponse.json(detail) : HttpResponse.json({ error: 'gone' }, { status: 404 })), + ) + } + + function manifestDetail(over: Record = {}, manifest: Record = {}) { + return { + id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify', + type: 'integration', tags: [], size: 4096, publishedAt: null, latest: '2.0.0', + manifest: { permissions: [], egress: [], settings: [], license: 'MIT', icon: null, operatorEgress: false, ...manifest }, + ...over, + } + } + + it('FE-COMP-PLUGINS-PANEL-040: a card shows its compact download count and falls back on a broken shot', async () => { + discoverWith({ downloadCount: 1234, screenshotUrl: 'https://example.com/shot.png', reviewedAt: '2026-01-05T00:00:00Z' }) + const { container } = render() + await clickDiscover() + + expect(await screen.findByText('1.2k')).toBeInTheDocument() + const img = container.querySelector('img') as HTMLImageElement + expect(screen.getByText('Reviewed')).toBeInTheDocument() + expect(img).toBeInTheDocument() + fireEvent.error(img) + await waitFor(() => expect(container.querySelector('img')).not.toBeInTheDocument()) + }) + + it('FE-COMP-PLUGINS-PANEL-040a: a two-digit download count is printed verbatim', async () => { + discoverWith({ downloadCount: 42 }) + render() + await clickDiscover() + + expect(await screen.findByText('42')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-041: opening a card by keyboard shows the modal, and a failed lookup says so', async () => { + discoverWith({}) + render() + await clickDiscover() + + const card = (await screen.findByText('Gotify')).closest('[role="button"]') as HTMLElement + fireEvent.keyDown(card, { key: 'Enter' }) + + expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-042: the detail modal lists setup fields, metadata and a separate homepage', async () => { + discoverWith( + { downloadCount: 999_500, reviewedAt: '2026-01-05T00:00:00Z', homepage: 'https://gotify.example', trek: '>=3.2.0' }, + manifestDetail({}, { + permissions: ['db:own'], + settings: [{ key: 'url', label: 'Server URL', scope: 'instance', required: true }], + }), + ) + render() + await clickDiscover() + fireEvent.click(await screen.findByText('Gotify')) + + expect(await screen.findByText('Server URL')).toBeInTheDocument() + expect(screen.getByText('Instance-wide')).toBeInTheDocument() + expect(screen.getByText('Required')).toBeInTheDocument() + expect(screen.getByText('4 KB')).toBeInTheDocument() + expect(screen.getByText('TREK >=3.2.0')).toBeInTheDocument() + expect(screen.getByText('Store its own data in an isolated database')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /homepage/i })).toHaveAttribute('href', 'https://gotify.example') + // 999 500 rounds to a whole megabyte on the card, never "1000k" + expect(screen.getByText('1M')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-043: a manifest with no readable access says exactly that', async () => { + discoverWith({ minTrekVersion: '3.2.0' }, manifestDetail({ size: 0 })) + render() + await clickDiscover() + fireEvent.click(await screen.findByText('Gotify')) + + expect(await screen.findByText('Needs no special access.')).toBeInTheDocument() + expect(screen.getByText('TREK 3.2.0+')).toBeInTheDocument() + expect(screen.queryByText('Connects to')).not.toBeInTheDocument() + expect(screen.queryByText('Setup')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-044: an incompatible entry explains the blocked button in the modal body', async () => { + discoverWith({ compatible: false, latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' }) + render() + await clickDiscover() + fireEvent.click(await screen.findByText('Gotify')) + + const explanations = await screen.findAllByText(/needs trek >=4\.0\.0 — this server runs 3\.3\.0/i) + expect(explanations.length).toBeGreaterThan(0) + }) + + it('FE-COMP-PLUGINS-PANEL-045: searching Discover with no hit says the registry has no match', async () => { + discoverWith({}) + render() + await clickDiscover() + await screen.findByText('Gotify') + + fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'nothing' } }) + + expect(screen.getByText('No plugins in the registry match your search.')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-046: installing from a card posts the offered version', async () => { + let body: unknown = null + discoverWith({ compatible: false, latestCompatible: '1.5.0', trek: '>=4.0.0', hostVersion: '3.3.0' }) + server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ ok: true }) + })) + withToast() + await clickDiscover() + + fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i })) + + await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' })) + }) + + it('FE-COMP-PLUGINS-PANEL-047: an unreachable registry leaves Discover empty rather than spinning', async () => { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.error()), + ) + render() + await screen.findByText('No plugins installed yet.') + + await clickDiscover() + + expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument() + }) +}) + +describe('AdminPluginsPanel — security footer', () => { + it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => { + panelWith([]) + render() + await screen.findByText('No plugins installed yet.') + + expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('How plugins are contained — and the limits')) + + expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument() + expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument() + expect(screen.getByText('What "Signed" means')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Admin/BackupPanel.test.tsx b/client/src/components/Admin/BackupPanel.test.tsx index 210117959..77f4b7dca 100644 --- a/client/src/components/Admin/BackupPanel.test.tsx +++ b/client/src/components/Admin/BackupPanel.test.tsx @@ -310,4 +310,292 @@ describe('BackupPanel', () => { expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled() }) }) + + // BKP-015: List request fails + it('FE-ADMIN-BKP-015: a failing list request toasts and keeps the empty state', async () => { + server.use(http.get('/api/backup/list', () => HttpResponse.error())) + render(<>) + + expect(await screen.findByText('Failed to load backups')).toBeInTheDocument() + expect(screen.getByText('No backups yet')).toBeInTheDocument() + }) + + // BKP-016: Create fails + it('FE-ADMIN-BKP-016: a failing create toasts the error and re-enables the button', async () => { + const user = userEvent.setup() + server.use(http.post('/api/backup/create', () => HttpResponse.error())) + render(<>) + await screen.findByText('backup-2025-01-15.zip') + + await user.click(screen.getByTitle('Create Backup')) + + expect(await screen.findByText('Failed to create backup')).toBeInTheDocument() + await waitFor(() => expect(screen.getByTitle('Create Backup')).toBeEnabled()) + }) + + // BKP-017: Restore fails + it('FE-ADMIN-BKP-017: a failing restore surfaces the server message and clears the spinner', async () => { + const user = userEvent.setup() + server.use( + http.post('/api/backup/restore/:filename', () => + HttpResponse.json({ error: 'archive is corrupt' }, { status: 400 }), + ), + ) + render(<>) + await screen.findByText('backup-2025-01-15.zip') + + await user.click(screen.getAllByText('Restore')[0]) + await user.click(await screen.findByText('Yes, restore')) + + expect(await screen.findByText('archive is corrupt')).toBeInTheDocument() + await waitFor(() => expect(screen.getAllByText('Restore')[0].closest('button')).toBeEnabled()) + }) + + // BKP-018: Upload & restore happy path + it('FE-ADMIN-BKP-018: picking a file opens the modal and uploads it on confirm', async () => { + const user = userEvent.setup() + let uploaded = false + server.use( + http.post('/api/backup/upload-restore', () => { + uploaded = true + return HttpResponse.json({ success: true }) + }), + ) + render(<>) + await screen.findByText('backup-2025-01-15.zip') + + const reloadMock = vi.fn() + vi.stubGlobal('location', { ...window.location, reload: reloadMock }) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + await user.upload(input, new File(['zip'], 'restore-me.zip', { type: 'application/zip' })) + + expect(await screen.findByText('Restore Backup?')).toBeInTheDocument() + expect(screen.getByText('restore-me.zip')).toBeInTheDocument() + // The picked file is cleared from the input so the same file can be chosen again + expect(input.value).toBe('') + + await user.click(screen.getByText('Yes, restore')) + await waitFor(() => expect(uploaded).toBe(true)) + expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument() + vi.unstubAllGlobals() + }) + + // BKP-019: Upload & restore failure + it('FE-ADMIN-BKP-019: a failing upload restore toasts and re-enables the upload button', async () => { + const user = userEvent.setup() + server.use( + http.post('/api/backup/upload-restore', () => HttpResponse.json({ error: 'not a backup' }, { status: 400 })), + ) + render(<>) + await screen.findByText('backup-2025-01-15.zip') + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + await user.upload(input, new File(['zip'], 'broken.zip', { type: 'application/zip' })) + await user.click(await screen.findByText('Yes, restore')) + + expect(await screen.findByText('not a backup')).toBeInTheDocument() + await waitFor(() => expect(screen.getByTitle('Upload Backup')).toBeEnabled()) + }) + + // BKP-020: Upload button forwards the click to the hidden file input + it('FE-ADMIN-BKP-020: the Upload button opens the hidden file picker', async () => { + const user = userEvent.setup() + render() + await screen.findByText('backup-2025-01-15.zip') + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {}) + await user.click(screen.getByTitle('Upload Backup')) + + expect(clickSpy).toHaveBeenCalled() + }) + + // BKP-021: Delete declined / failing + it('FE-ADMIN-BKP-021: declining the confirm keeps the backup, a failing delete toasts', async () => { + const user = userEvent.setup() + let deleteCalls = 0 + server.use( + http.delete('/api/backup/:filename', () => { + deleteCalls += 1 + return HttpResponse.json({ error: 'file is locked' }, { status: 500 }) + }), + ) + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + render(<>) + await screen.findByText('backup-2025-01-15.zip') + + const trashBtn = Array.from(document.querySelectorAll('button')).find( + b => b.querySelector('svg.lucide-trash2'), + ) as HTMLElement + await user.click(trashBtn) + expect(deleteCalls).toBe(0) + expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument() + + confirmSpy.mockReturnValue(true) + await user.click(trashBtn) + + expect(await screen.findByText('Failed to delete')).toBeInTheDocument() + expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument() + }) + + // BKP-022: Auto settings save fails + it('FE-ADMIN-BKP-022: a failing auto-settings save toasts and keeps the form dirty', async () => { + const user = userEvent.setup() + server.use(http.put('/api/backup/auto-settings', () => HttpResponse.error())) + render(<>) + await screen.findByText('Enable auto-backup') + + await user.click(getToggleButton()) + await user.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('Failed to save settings')).toBeInTheDocument() + await waitFor(() => expect(screen.getByRole('button', { name: /^save$/i })).toBeEnabled()) + }) + + // BKP-023: Size/date fallbacks + it('FE-ADMIN-BKP-023: missing size and date render as a dash, kilobytes are formatted', async () => { + server.use( + http.get('/api/backup/list', () => + HttpResponse.json({ + backups: [ + { filename: 'empty.zip', created_at: null, size: 0 }, + { filename: 'small.zip', created_at: '2025-03-01T08:00:00Z', size: 5120 }, + ], + }), + ), + ) + render() + await screen.findByText('empty.zip') + + expect(screen.getAllByText('-')).toHaveLength(2) + expect(screen.getByText('5.0 KB')).toBeInTheDocument() + }) + + // BKP-024: Invalid server timezone + it('FE-ADMIN-BKP-024: an unusable server timezone falls back to the raw timestamp', async () => { + server.use( + http.get('/api/backup/auto-settings', () => + HttpResponse.json({ + settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }, + timezone: 'Not/AZone', + }), + ), + ) + render() + await screen.findByText('backup-2025-01-15.zip') + + await waitFor(() => expect(screen.getByText('2025-01-15T10:00:00Z')).toBeInTheDocument()) + }) + + // BKP-025: 12h hour picker + it('FE-ADMIN-BKP-025: the hour picker uses AM/PM labels for 12h users and stores the pick', async () => { + const user = userEvent.setup() + seedStore(useSettingsStore, { settings: { time_format: '12h' } } as any) + let saved: Record | null = null + server.use( + http.get('/api/backup/auto-settings', () => + HttpResponse.json({ + settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 0, day_of_week: 0, day_of_month: 1 }, + timezone: 'UTC', + }), + ), + http.put('/api/backup/auto-settings', async ({ request }) => { + saved = await request.json() as Record + return HttpResponse.json({ settings: saved }) + }), + ) + render(<>) + await screen.findByText('Run at hour') + + expect(screen.getByText('Server local time (12h format) (Timezone: UTC)')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '12:00 AM' })) + await user.click(await screen.findByRole('button', { name: '2:00 PM' })) + await user.click(screen.getByRole('button', { name: /^save$/i })) + + await waitFor(() => expect(saved).toMatchObject({ hour: 14 })) + }) + + // BKP-026: Monthly interval + it('FE-ADMIN-BKP-026: the monthly interval offers a day-of-month picker', async () => { + const user = userEvent.setup() + let saved: Record | null = null + server.use( + http.get('/api/backup/auto-settings', () => + HttpResponse.json({ + settings: { enabled: true, interval: 'monthly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }, + timezone: '', + }), + ), + http.put('/api/backup/auto-settings', async ({ request }) => { + saved = await request.json() as Record + return HttpResponse.json({ settings: saved }) + }), + ) + render(<>) + await screen.findByText('Day of month') + + // No timezone from the server → the hint carries no timezone suffix + expect(screen.getByText('Server local time (24h format)')).toBeInTheDocument() + expect(screen.queryByText('Sun')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '1' })) + await user.click(await screen.findByRole('button', { name: '15' })) + await user.click(screen.getByRole('button', { name: /^save$/i })) + + await waitFor(() => expect(saved).toMatchObject({ day_of_month: 15 })) + }) + + // BKP-027: Day of week + retention + it('FE-ADMIN-BKP-027: day-of-week and retention picks are stored together', async () => { + const user = userEvent.setup() + let saved: Record | null = null + server.use( + http.get('/api/backup/auto-settings', () => + HttpResponse.json({ + settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }, + timezone: 'UTC', + }), + ), + http.put('/api/backup/auto-settings', async ({ request }) => { + saved = await request.json() as Record + return HttpResponse.json({ settings: saved }) + }), + ) + render(<>) + await screen.findByText('Day of week') + + await user.click(screen.getByText('Fri')) + await user.click(screen.getByText('Keep forever')) + await user.click(screen.getByRole('button', { name: /^save$/i })) + + await waitFor(() => expect(saved).toMatchObject({ day_of_week: 5, keep_days: 0 })) + }) + + // BKP-028: Download failure + it('FE-ADMIN-BKP-028: a failing download toasts the download error', async () => { + const user = userEvent.setup() + server.use(http.get('/api/backup/download/:filename', () => HttpResponse.error())) + render(<>) + await screen.findByText('backup-2025-01-15.zip') + + await user.click(screen.getByText('Download')) + + expect(await screen.findByText('Download failed')).toBeInTheDocument() + }) + + // BKP-029: Confirm button hover styling + it('FE-ADMIN-BKP-029: the destructive confirm button darkens on hover', async () => { + const user = userEvent.setup() + render() + await screen.findByText('backup-2025-01-15.zip') + + await user.click(screen.getAllByText('Restore')[0]) + const confirmBtn = await screen.findByText('Yes, restore') + + fireEvent.mouseEnter(confirmBtn) + expect(confirmBtn.style.background).toBe('rgb(185, 28, 28)') + fireEvent.mouseLeave(confirmBtn) + expect(confirmBtn.style.background).toBe('rgb(220, 38, 38)') + }) }) diff --git a/client/src/components/Admin/CategoryManager.test.tsx b/client/src/components/Admin/CategoryManager.test.tsx index 5145d468f..d3e5173cb 100644 --- a/client/src/components/Admin/CategoryManager.test.tsx +++ b/client/src/components/Admin/CategoryManager.test.tsx @@ -1,5 +1,5 @@ -// FE-COMP-CAT-001 to FE-COMP-CAT-012 -import { render, screen, waitFor } from '../../../tests/helpers/render'; +// FE-COMP-CAT-001 to FE-COMP-CAT-020 +import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; @@ -156,4 +156,148 @@ describe('CategoryManager', () => { await user.click(screen.getByText('Cancel')); expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument(); }); + + it('FE-COMP-CAT-013: a failing list request toasts and falls back to the empty state', async () => { + server.use(http.get('/api/categories', () => HttpResponse.error())); + render(<>); + + expect(await screen.findByText('Failed to load categories')).toBeInTheDocument(); + expect(screen.getByText('No categories yet')).toBeInTheDocument(); + }); + + it('FE-COMP-CAT-014: editing a category sends a PUT and replaces the row', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use( + http.get('/api/categories', () => + HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels', color: '#6366f1', icon: 'MapPin' })] }) + ), + http.put('/api/categories/5', async ({ request }) => { + body = await request.json() as Record; + return HttpResponse.json({ category: buildCategory({ id: 5, name: 'Lodging', color: '#ef4444', icon: 'BedDouble' }) }); + }), + ); + render(<>); + await screen.findByText('Hotels'); + + await user.click(screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'))[0]); + const nameInput = screen.getByDisplayValue('Hotels'); + await user.clear(nameInput); + await user.type(nameInput, 'Lodging'); + await user.click(screen.getByTitle('Hotel')); + await user.click(screen.getByText('Update')); + + expect(await screen.findByText('Category updated')).toBeInTheDocument(); + expect(body).toEqual({ name: 'Lodging', color: '#6366f1', icon: 'BedDouble' }); + expect(screen.getByText('Lodging')).toBeInTheDocument(); + }); + + it('FE-COMP-CAT-015: a failing save surfaces the server message', async () => { + const user = userEvent.setup(); + server.use( + http.post('/api/categories', () => HttpResponse.json({ error: 'name already taken' }, { status: 409 })), + ); + render(<>); + await screen.findByText('New Category'); + + await user.click(screen.getByText('New Category')); + await user.type(screen.getByPlaceholderText('Category name'), 'Parks'); + await user.click(screen.getByText('Create')); + + expect(await screen.findByText('name already taken')).toBeInTheDocument(); + // The form stays open so the name can be corrected + expect(screen.getByDisplayValue('Parks')).toBeInTheDocument(); + }); + + it('FE-COMP-CAT-016: declining the delete confirm keeps the category', async () => { + const user = userEvent.setup(); + let deleteCalled = false; + server.use( + http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })), + http.delete('/api/categories/9', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }), + ); + vi.spyOn(window, 'confirm').mockReturnValue(false); + render(); + await screen.findByText('Parks'); + + const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category')); + await user.click(actionBtns[1]); + + expect(deleteCalled).toBe(false); + expect(screen.getByText('Parks')).toBeInTheDocument(); + vi.restoreAllMocks(); + }); + + it('FE-COMP-CAT-017: a failing delete toasts and keeps the row', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })), + http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 })), + ); + vi.spyOn(window, 'confirm').mockReturnValue(true); + render(<>); + await screen.findByText('Parks'); + + const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category')); + await user.click(actionBtns[1]); + + expect(await screen.findByText('category in use')).toBeInTheDocument(); + expect(screen.getByText('Parks')).toBeInTheDocument(); + vi.restoreAllMocks(); + }); + + it('FE-COMP-CAT-018: picking an icon and a preset colour updates the live preview', async () => { + const user = userEvent.setup(); + render(); + await screen.findByText('New Category'); + await user.click(screen.getByText('New Category')); + + // Empty name → the preview falls back to the generic label + expect(screen.getByText('Category')).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText('Category name'), 'Beach day'); + await user.click(screen.getByTitle('Beach')); + + const preview = screen.getByText('Beach day'); + expect(preview).toHaveStyle({ color: '#6366f1' }); + + await user.click(document.querySelectorAll('button[style*="background-color: rgb(239, 68, 68)"]')[0]); + expect(screen.getByText('Beach day')).toHaveStyle({ color: '#ef4444' }); + }); + + it('FE-COMP-CAT-019: the custom colour swatch opens the native picker and adopts its value', async () => { + const user = userEvent.setup(); + render(); + await screen.findByText('New Category'); + await user.click(screen.getByText('New Category')); + + const colorInput = document.querySelector('input[type="color"]') as HTMLInputElement; + const clickSpy = vi.spyOn(colorInput, 'click').mockImplementation(() => {}); + await user.click(screen.getByTitle('Choose custom color')); + expect(clickSpy).toHaveBeenCalled(); + + fireEvent.change(colorInput, { target: { value: '#123456' } }); + await waitFor(() => expect(screen.getByText('Category')).toHaveStyle({ color: '#123456' })); + // A non-preset colour fills the custom swatch instead of showing the pipette + expect(screen.getByTitle('Choose custom color')).toHaveStyle({ backgroundColor: '#123456' }); + vi.restoreAllMocks(); + }); + + it('FE-COMP-CAT-020: starting an edit closes the create form', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 3, name: 'Hotels' })] })), + ); + render(); + await screen.findByText('Hotels'); + + await user.click(screen.getByText('New Category')); + expect(screen.getByPlaceholderText('Category name')).toHaveValue(''); + + const row = screen.getByText('Hotels').closest('.p-3') as HTMLElement; + await user.click(within(row).getAllByRole('button')[0]); + + // Only the inline edit form remains, pre-filled with the row's name + expect(screen.getAllByPlaceholderText('Category name')).toHaveLength(1); + expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Admin/DefaultUserSettingsTab.test.tsx b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx new file mode 100644 index 000000000..f61fc4e03 --- /dev/null +++ b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx @@ -0,0 +1,422 @@ +// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-025 +import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { buildAdmin } from '../../../tests/helpers/factories'; +import { useAuthStore } from '../../store/authStore'; +import { ToastContainer } from '../shared/Toast'; +import DefaultUserSettingsTab from './DefaultUserSettingsTab'; + +// The tile preview would pull Leaflet into jsdom; the panel only needs it to render. +vi.mock('../Map/MapView', () => ({ + MapView: ({ tileUrl }: { tileUrl?: string }) =>
, +})); + +const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard'; +const MAPBOX_DARK = 'mapbox://styles/mapbox/dark-v11'; +const MAPBOX_NAV_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1'; +const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty'; +const TILE_PLACEHOLDER = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; + +/** Stateful stand-in for the admin defaults endpoint: PUT merges, null deletes. */ +function stubDefaults(initial: Record = {}) { + const state: Record = { ...initial }; + const puts: Record[] = []; + server.use( + http.get('/api/admin/default-user-settings', () => HttpResponse.json(state)), + http.put('/api/admin/default-user-settings', async ({ request }) => { + const body = await request.json() as Record; + puts.push(body); + for (const [key, value] of Object.entries(body)) { + if (value === null) delete state[key]; + else state[key] = value; + } + return HttpResponse.json({ ...state }); + }), + ); + return { puts, state }; +} + +function withToast() { + return render(<>); +} + +/** The selected option button is the one drawn with the strong border token. */ +function isActive(button: HTMLElement): boolean { + return (button.style.border || '').includes('var(--text-primary)'); +} + +/** + * The reset link sits inside the field's own