feat(web/m7-user-mgmt): /reset-password page + admin SMTP card + tests (U3-T5b)
Completes the U3 frontend that T5a's crashed dispatch left half-done. - /reset-password/[token] — public; new password + confirm. Calls POST /api/auth/reset-password with the URL's token. invalid_token, password_too_short, and mismatched-passwords surface as inline errors. Success redirects to /login?reset=ok. - /admin/integrations gains an SMTP card (alongside Lidarr) with enabled toggle + all fields + Save and Send-test-email buttons. Password input is a separate state from the loaded form, so empty submission preserves the stored server-side value (matches the backend's empty-password-preserves contract). Test button error codes (no_email_on_file / not_configured / send_failed) surface as actionable toasts. Tests cover both new pages plus extensions to settings + integrations test files for the cards landed in T5a. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,13 @@ vi.mock('$lib/api/listenbrainz', () => ({
|
||||
setListenBrainzEnabled: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/me', () => ({
|
||||
updateProfile: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
getAPIToken: vi.fn(),
|
||||
regenerateAPIToken: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
@@ -27,6 +34,12 @@ import {
|
||||
setListenBrainzToken,
|
||||
setListenBrainzEnabled
|
||||
} from '$lib/api/listenbrainz';
|
||||
import {
|
||||
updateProfile,
|
||||
changePassword,
|
||||
getAPIToken,
|
||||
regenerateAPIToken
|
||||
} from '$lib/api/me';
|
||||
|
||||
function mockStatusStore(data: LBStatus) {
|
||||
return readable({ data, isPending: false, isError: false });
|
||||
@@ -100,3 +113,87 @@ describe('Settings page — ListenBrainz', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Shared setup for cards that don't need LB state
|
||||
function setupPage() {
|
||||
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
||||
);
|
||||
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
(getAPIToken as ReturnType<typeof vi.fn>).mockResolvedValue({ api_token: 'tok_abc123' });
|
||||
}
|
||||
|
||||
describe('Settings page — Profile card', () => {
|
||||
test('Save profile calls updateProfile with form values', async () => {
|
||||
setupPage();
|
||||
(updateProfile as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
render(SettingsPage);
|
||||
await fireEvent.input(screen.getByLabelText(/display name/i), { target: { value: 'Alice' } });
|
||||
await fireEvent.input(screen.getByLabelText(/email/i), { target: { value: 'alice@example.com' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
||||
await waitFor(() =>
|
||||
expect(updateProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ display_name: 'Alice', email: 'alice@example.com' })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('Save profile shows toast on success', async () => {
|
||||
setupPage();
|
||||
(updateProfile as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
render(SettingsPage);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/profile saved/i));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings page — Password card', () => {
|
||||
test('mismatched passwords show toast and do not call changePassword', async () => {
|
||||
setupPage();
|
||||
render(SettingsPage);
|
||||
await fireEvent.input(screen.getByLabelText(/current password/i), { target: { value: 'oldpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/new password/i), { target: { value: 'newpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/confirm/i), { target: { value: 'different1' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(changePassword).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/do not match/i));
|
||||
});
|
||||
|
||||
test('matched passwords call changePassword(current, new)', async () => {
|
||||
setupPage();
|
||||
(changePassword as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
render(SettingsPage);
|
||||
await fireEvent.input(screen.getByLabelText(/current password/i), { target: { value: 'oldpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/new password/i), { target: { value: 'newpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/confirm/i), { target: { value: 'newpw1234' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change password/i }));
|
||||
await waitFor(() =>
|
||||
expect(changePassword).toHaveBeenCalledWith('oldpw1234', 'newpw1234')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings page — API Token card', () => {
|
||||
test('first click on Regenerate shows "Click again to confirm"', async () => {
|
||||
setupPage();
|
||||
render(SettingsPage);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /regenerate/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /click again to confirm/i })).toBeInTheDocument()
|
||||
);
|
||||
expect(regenerateAPIToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('second click within 5s calls regenerateAPIToken', async () => {
|
||||
setupPage();
|
||||
(regenerateAPIToken as ReturnType<typeof vi.fn>).mockResolvedValue({ api_token: 'new_tok_xyz' });
|
||||
render(SettingsPage);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /regenerate/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /click again to confirm/i })).toBeInTheDocument()
|
||||
);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /click again to confirm/i }));
|
||||
await waitFor(() => expect(regenerateAPIToken).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user