import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import { AuthProvider } from '../src/auth.js' import SettingsPage from '../src/pages/SettingsPage.js' import type { SettingsView, SyncState, User } from '../src/types.js' vi.mock('../src/api.js', async (importOriginal) => { const actual = await importOriginal() return { ...actual, api: { ...actual.api, getSettings: vi.fn(), putSettings: vi.fn(), syncStatus: vi.fn(), startSync: vi.fn(), listUsers: vi.fn(), createUser: vi.fn(), deleteUser: vi.fn(), logout: vi.fn(), triggerBackup: vi.fn(), getBackups: vi.fn(), }, } }) import { api } from '../src/api.js' const emptyView: SettingsView = { hasDiscogsToken: false, discogsTokenMasked: null, subsonicUrl: null, subsonicUsername: null, hasSubsonicPassword: false, } const idleSync: SyncState = { status: 'idle', error: null, lastSyncedAt: null, albums: 0 } const admin: User = { id: 1, username: 'sam', isAdmin: true } function jsonOk(body: unknown) { return Promise.resolve( new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }) ) } // AuthProvider reads /api/setup and /api/me via fetch; stub an authed session function stubAuthFetch(user: User) { vi.stubGlobal( 'fetch', vi.fn((url: string) => { if (url === '/api/setup') return jsonOk({ needed: false }) return jsonOk({ user }) }) ) } beforeEach(() => { for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout, api.triggerBackup, api.getBackups] as const) { vi.mocked(fn).mockReset() } vi.mocked(api.getSettings).mockResolvedValue(emptyView as never) vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never) vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never) vi.mocked(api.getBackups).mockResolvedValue({ backups: [] } as never) stubAuthFetch(admin) }) function renderSettings() { return render( ) } describe('SettingsPage', () => { it('saves the discogs token and shows the mask', async () => { vi.mocked(api.putSettings).mockResolvedValue({ ...emptyView, hasDiscogsToken: true, discogsTokenMasked: '****6789' } as never) renderSettings() await userEvent.type(await screen.findByLabelText(/discogs token/i), 'abcdef0123456789') await userEvent.click(screen.getByRole('button', { name: /save discogs/i })) await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ discogsToken: 'abcdef0123456789' })) await waitFor(() => expect(screen.getByText(/token saved/i)).toBeTruthy()) }) it('clears the discogs token with an empty save', async () => { vi.mocked(api.getSettings).mockResolvedValue({ ...emptyView, hasDiscogsToken: true, discogsTokenMasked: '****6789' } as never) vi.mocked(api.putSettings).mockResolvedValue(emptyView as never) renderSettings() await userEvent.click(await screen.findByRole('button', { name: /save discogs/i })) await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ discogsToken: '' })) }) it('saves subsonic config and surfaces validation errors', async () => { const err = new (await import('../src/api.js')).ApiError(400, 'subsonic_unreachable', 'could not reach http://x') vi.mocked(api.putSettings).mockRejectedValue(err) renderSettings() await userEvent.type(await screen.findByLabelText(/subsonic url/i), 'http://x') await userEvent.type(screen.getByLabelText(/subsonic username/i), 'sam') await userEvent.type(screen.getByLabelText(/subsonic password/i), 'pass') await userEvent.click(screen.getByRole('button', { name: /save subsonic/i })) await waitFor(() => expect(screen.getByText(/could not reach http:\/\/x/i)).toBeTruthy()) }) it('shows sync state and triggers a sync', async () => { vi.mocked(api.startSync).mockResolvedValue({ ...idleSync, status: 'running' } as never) vi.mocked(api.syncStatus).mockResolvedValue({ status: 'done', error: null, lastSyncedAt: '2026-08-29T12:00:00.000Z', albums: 503, } as never) renderSettings() await userEvent.click(await screen.findByRole('button', { name: /sync now/i })) await waitFor(() => expect(api.startSync).toHaveBeenCalled()) await waitFor(() => expect(screen.getByText(/503 albums/i)).toBeTruthy()) }) it('admin manages users', async () => { vi.mocked(api.createUser).mockResolvedValue({ id: 2, username: 'bob', isAdmin: false } as never) renderSettings() expect((await screen.findAllByText('sam')).length).toBeGreaterThan(0) await userEvent.type(screen.getByLabelText(/new username/i), 'bob') await userEvent.type(screen.getByLabelText(/new password/i), 'bobpass123') await userEvent.click(screen.getByRole('button', { name: /add user/i })) await waitFor(() => expect(api.createUser).toHaveBeenCalledWith('bob', 'bobpass123')) expect(await screen.findByText('bob')).toBeTruthy() vi.mocked(api.deleteUser).mockResolvedValue({ ok: true } as never) await userEvent.click(screen.getByRole('button', { name: /remove bob/i })) await waitFor(() => expect(api.deleteUser).toHaveBeenCalledWith(2)) }) it('hides user management from non-admins', async () => { stubAuthFetch({ id: 2, username: 'bob', isAdmin: false }) renderSettings() expect(await screen.findByText('bob')).toBeTruthy() expect(screen.queryByLabelText(/discogs token/i)).toBeTruthy() expect(screen.queryByLabelText(/new username/i)).toBeNull() }) it('logout button calls the api', async () => { vi.mocked(api.logout).mockResolvedValue({ ok: true } as never) renderSettings() await userEvent.click(await screen.findByRole('button', { name: /log out/i })) expect(api.logout).toHaveBeenCalled() }) it('sync now surfaces the no-config error', async () => { const err = new (await import('../src/api.js')).ApiError(409, 'no_subsonic_config') vi.mocked(api.startSync).mockRejectedValue(err) renderSettings() await userEvent.click(await screen.findByRole('button', { name: /sync now/i })) await waitFor(() => expect(screen.getByText(/no_subsonic_config/)).toBeTruthy()) }) it('keeps the stored subsonic password when saving with a blank password field', async () => { vi.mocked(api.putSettings).mockResolvedValue(emptyView as never) renderSettings() await userEvent.type(await screen.findByLabelText(/subsonic url/i), 'http://navidrome.local') await userEvent.type(screen.getByLabelText(/subsonic username/i), 'sam') // password left blank await userEvent.click(screen.getByRole('button', { name: /save subsonic/i })) await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ subsonicUrl: 'http://navidrome.local', subsonicUsername: 'sam' }) ) }) it('polls sync status until it finishes after sync now', async () => { vi.mocked(api.startSync).mockResolvedValue({ ...idleSync, status: 'running' } as never) vi.mocked(api.syncStatus) .mockResolvedValueOnce({ ...idleSync } as never) // mount poll: idle, chain ends .mockResolvedValueOnce({ ...idleSync, status: 'running' } as never) // first poll after sync now .mockResolvedValue({ ...idleSync, status: 'done', albums: 42, lastSyncedAt: '2026-08-29T12:00:00.000Z' } as never) renderSettings() await userEvent.click(await screen.findByRole('button', { name: /sync now/i })) await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 }) expect(vi.mocked(api.syncStatus).mock.calls.length).toBeGreaterThanOrEqual(3) }) it('data section: export link, backup now, backups list', async () => { vi.mocked(api.triggerBackup).mockResolvedValue({ file: 'record-shop-2026-09-03-120000.db' } as never) vi.mocked(api.getBackups).mockResolvedValue({ backups: [{ file: 'record-shop-2026-09-03-120000.db', sizeBytes: 12345, createdAt: '2026-09-03T12:00:00Z' }], } as never) renderSettings() expect(await screen.findByRole('link', { name: /export json/i })).toHaveProperty('href') await userEvent.click(screen.getByRole('button', { name: /back up now/i })) await waitFor(() => expect(api.triggerBackup).toHaveBeenCalled()) await waitFor(() => expect(screen.getByText(/record-shop-2026-09-03-120000\.db/)).toBeTruthy()) }) it('backup failure surfaces in the flash banner', async () => { vi.mocked(api.triggerBackup).mockRejectedValue(new TypeError('fetch failed')) renderSettings() await userEvent.click(await screen.findByRole('button', { name: /back up now/i })) await waitFor(() => expect(screen.getByText(/backup failed/i)).toBeTruthy()) }) })