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:
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { LidarrConfig } from '$lib/api/types';
|
||||
import type { CoverProvider } from '$lib/api/admin';
|
||||
import type { CoverProvider, SMTPConfig } from '$lib/api/admin';
|
||||
|
||||
// useQueryClient is wrapped so the page can call invalidateQueries() without
|
||||
// a real QueryClient context. Everything else from svelte-query passes through.
|
||||
@@ -21,7 +21,10 @@ vi.mock('$lib/api/admin', () => ({
|
||||
testLidarrConnection: vi.fn(),
|
||||
createCoverProvidersQuery: vi.fn(),
|
||||
updateCoverProvider: vi.fn(),
|
||||
testCoverProvider: vi.fn()
|
||||
testCoverProvider: vi.fn(),
|
||||
createSMTPConfigQuery: vi.fn(),
|
||||
updateSMTPConfig: vi.fn(),
|
||||
testSMTPConfig: vi.fn()
|
||||
}));
|
||||
|
||||
import IntegrationsPage from './+page.svelte';
|
||||
@@ -34,7 +37,10 @@ import {
|
||||
testLidarrConnection,
|
||||
createCoverProvidersQuery,
|
||||
updateCoverProvider,
|
||||
testCoverProvider
|
||||
testCoverProvider,
|
||||
createSMTPConfigQuery,
|
||||
updateSMTPConfig,
|
||||
testSMTPConfig
|
||||
} from '$lib/api/admin';
|
||||
|
||||
const cfgConnected: LidarrConfig = {
|
||||
@@ -88,12 +94,34 @@ const defaultCoverProviders = {
|
||||
sources_version: 1
|
||||
};
|
||||
|
||||
const smtpUnset: SMTPConfig = {
|
||||
enabled: false,
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
from_name: 'Minstrel',
|
||||
use_tls: true
|
||||
};
|
||||
|
||||
const smtpConfigured: SMTPConfig = {
|
||||
enabled: true,
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '***',
|
||||
from_address: 'noreply@example.com',
|
||||
from_name: 'Minstrel',
|
||||
use_tls: true
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
invalidateQueries.mockReset();
|
||||
});
|
||||
|
||||
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null } = {}) {
|
||||
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; smtp?: SMTPConfig } = {}) {
|
||||
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: opts.config ?? cfgConnected })
|
||||
);
|
||||
@@ -120,6 +148,9 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov
|
||||
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
|
||||
);
|
||||
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: opts.smtp ?? smtpUnset })
|
||||
);
|
||||
return render(IntegrationsPage);
|
||||
}
|
||||
|
||||
@@ -412,3 +443,68 @@ describe('/admin/integrations — cover art providers', () => {
|
||||
expect(screen.getByText(/loading…/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function smtpSection(): HTMLElement {
|
||||
return screen.getByRole('heading', { name: /email \(smtp\)/i, level: 3 }).closest('section') as HTMLElement;
|
||||
}
|
||||
|
||||
describe('/admin/integrations — SMTP card', () => {
|
||||
test('renders "Email (SMTP)" heading', () => {
|
||||
setup();
|
||||
expect(screen.getByRole('heading', { name: /email \(smtp\)/i, level: 3 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Save calls updateSMTPConfig with form values; empty password preserves stored value', async () => {
|
||||
setup({ smtp: smtpConfigured });
|
||||
(updateSMTPConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
// password input starts empty — should send '' which tells backend to preserve
|
||||
const section = smtpSection();
|
||||
await fireEvent.click(within(section).getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() =>
|
||||
expect(updateSMTPConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ password: '' })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('Save with a typed password sends the typed value', async () => {
|
||||
setup({ smtp: smtpConfigured });
|
||||
(updateSMTPConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
const section = smtpSection();
|
||||
const pwInput = within(section).getByLabelText(/password/i);
|
||||
await fireEvent.input(pwInput, { target: { value: 'newsecret' } });
|
||||
await fireEvent.click(within(section).getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() =>
|
||||
expect(updateSMTPConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ password: 'newsecret' })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('Test button calls testSMTPConfig', async () => {
|
||||
setup();
|
||||
(testSMTPConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
const section = smtpSection();
|
||||
await fireEvent.click(within(section).getByRole('button', { name: /send test email/i }));
|
||||
await waitFor(() => expect(testSMTPConfig).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test('no_email_on_file error from testSMTPConfig surfaces actionable toast', async () => {
|
||||
setup();
|
||||
(testSMTPConfig as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ code: 'no_email_on_file' });
|
||||
const section = smtpSection();
|
||||
await fireEvent.click(within(section).getByRole('button', { name: /send test email/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('toast').textContent).toMatch(/set your email in \/settings/i)
|
||||
);
|
||||
});
|
||||
|
||||
test('password input placeholder shows hint when existing password is masked', async () => {
|
||||
setup({ smtp: smtpConfigured });
|
||||
// smtpConfigured has password: '***' — placeholder should show "Leave blank to keep current"
|
||||
const section = smtpSection();
|
||||
await waitFor(() =>
|
||||
expect(within(section).getByPlaceholderText(/leave blank to keep current/i)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user