1
0

feat: settings data section with export and backups

This commit is contained in:
2026-09-03 23:17:21 +02:00
parent b33399c865
commit 9b2bc22d59
2 changed files with 66 additions and 2 deletions

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNod
import { useNavigate } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import type { SettingsView, SyncState, User } from '../types.js'
import type { BackupFile, SettingsView, SyncState, User } from '../types.js'
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
@@ -29,6 +29,7 @@ export default function SettingsPage() {
const [sync, setSync] = useState<SyncState | null>(null)
const [users, setUsers] = useState<User[] | null>(null)
const [backups, setBackups] = useState<BackupFile[] | null>(null)
const [newUsername, setNewUsername] = useState('')
const [newPassword, setNewPassword] = useState('')
@@ -81,6 +82,7 @@ export default function SettingsPage() {
.listUsers()
.then((res) => setUsers(res.users))
.catch(() => {})
void api.getBackups().then((res) => setBackups(res.backups)).catch(() => {})
}
}, [user])
@@ -237,6 +239,46 @@ export default function SettingsPage() {
</button>
</Section>
{user?.isAdmin && (
<Section title="Data">
<div className="flex flex-wrap gap-2">
<a
href={api.exportUrl()}
download
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Export JSON
</a>
<button
type="button"
onClick={() =>
void api
.triggerBackup()
.then(() => api.getBackups())
.then((res) => setBackups(res.backups))
.then(() => flash('ok', 'Backup created'))
.catch(() => flash('error', 'Backup failed'))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Back up now
</button>
</div>
{backups && backups.length > 0 && (
<ul className="space-y-1 text-xs text-neutral-400">
{backups.map((b) => (
<li key={b.file} className="flex justify-between gap-2">
<span className="truncate">{b.file}</span>
<span className="shrink-0">
{(b.sizeBytes / 1024).toFixed(0)} KB · {new Date(b.createdAt).toLocaleString()}
</span>
</li>
))}
</ul>
)}
</Section>
)}
{user?.isAdmin && (
<Section title="Users">
<ul className="space-y-1.5 text-sm">

View File

@@ -20,6 +20,8 @@ vi.mock('../src/api.js', async (importOriginal) => {
createUser: vi.fn(),
deleteUser: vi.fn(),
logout: vi.fn(),
triggerBackup: vi.fn(),
getBackups: vi.fn(),
},
}
})
@@ -56,12 +58,13 @@ function stubAuthFetch(user: User) {
}
beforeEach(() => {
for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout] as const) {
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)
})
@@ -179,4 +182,23 @@ describe('SettingsPage', () => {
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())
})
})