2026-08-30 00:00:44 +02:00
|
|
|
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<typeof import('../src/api.js')>()
|
|
|
|
|
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(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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] 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)
|
|
|
|
|
stubAuthFetch(admin)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function renderSettings() {
|
|
|
|
|
return render(
|
|
|
|
|
<MemoryRouter>
|
|
|
|
|
<AuthProvider>
|
|
|
|
|
<SettingsPage />
|
|
|
|
|
</AuthProvider>
|
|
|
|
|
</MemoryRouter>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
})
|
2026-08-30 00:16:41 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
2026-08-30 00:00:44 +02:00
|
|
|
})
|