From 9b2bc22d59f6cf512b00477258eec8fb8e5a033f Mon Sep 17 00:00:00 2001 From: Samu Date: Thu, 3 Sep 2026 23:17:21 +0200 Subject: [PATCH] feat: settings data section with export and backups --- web/src/pages/SettingsPage.tsx | 44 +++++++++++++++++++++++++++++++++- web/test/settings.test.tsx | 24 ++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index c9f9ee6..560e4c5 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -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(null) const [users, setUsers] = useState(null) + const [backups, setBackups] = useState(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() { + {user?.isAdmin && ( +
+
+ + Export JSON + + +
+ {backups && backups.length > 0 && ( +
    + {backups.map((b) => ( +
  • + {b.file} + + {(b.sizeBytes / 1024).toFixed(0)} KB ยท {new Date(b.createdAt).toLocaleString()} + +
  • + ))} +
+ )} +
+ )} + {user?.isAdmin && (
    diff --git a/web/test/settings.test.tsx b/web/test/settings.test.tsx index 432925d..0735de6 100644 --- a/web/test/settings.test.tsx +++ b/web/test/settings.test.tsx @@ -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()) + }) })