Files
minstrel/web/src/routes/login/login.test.ts
T
bvandeusen 88961596b1 fix(web): rename route-colocated test to avoid +page.* collision
+page.test.ts triggered svelte-kit sync's route walker (which treats
any +-prefixed file as a route). Renaming to login.test.ts lets the
sync pass without disabling it in scripts, preserving the upstream
guarantee that types in .svelte-kit/ are fresh before check/test.

Plan bug introduced in Task 10 (filename chosen in the plan); fixing
here and updating the plan so re-runs use the correct name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 06:10:11 -04:00

123 lines
4.4 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
// vi.hoisted avoids the temporal-dead-zone hazard: mock factories are lazy
// and can run before top-level `let` is initialized, since imports are
// hoisted above variable declarations.
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/login') }));
vi.mock('$app/state', () => ({
page: {
get url() { return state.pageUrl; }
}
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
vi.mock('$lib/auth/store.svelte', () => ({
login: vi.fn(),
user: { value: null }
}));
import LoginPage from './+page.svelte';
import { login } from '$lib/auth/store.svelte';
import { goto } from '$app/navigation';
afterEach(() => {
vi.clearAllMocks();
state.pageUrl = new URL('http://localhost/login');
});
async function submit(username = 'alice', password = 'pw') {
const u = screen.getByLabelText(/username/i) as HTMLInputElement;
const p = screen.getByLabelText(/password/i) as HTMLInputElement;
await fireEvent.input(u, { target: { value: username } });
await fireEvent.input(p, { target: { value: password } });
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
}
describe('login page', () => {
test('renders username, password, and sign-in button', () => {
render(LoginPage);
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
test('invalid credentials show inline error and clear password', async () => {
(login as ReturnType<typeof vi.fn>).mockRejectedValue({
code: 'invalid_credentials', message: 'bad creds', status: 401
});
render(LoginPage);
await submit();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/invalid username or password/i);
});
expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('');
expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice');
});
test('server error shows generic message and preserves both fields', async () => {
(login as ReturnType<typeof vi.fn>).mockRejectedValue({
code: 'server_error', message: 'boom', status: 500
});
render(LoginPage);
await submit('alice', 'pw');
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/try again in a moment/i);
});
expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('pw');
expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice');
});
test('success navigates to returnTo when safe', async () => {
state.pageUrl = new URL('http://localhost/login?returnTo=/artists/abc');
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(LoginPage);
await submit();
await waitFor(() => {
expect(goto).toHaveBeenCalledWith('/artists/abc', { replaceState: true });
});
});
test('success falls back to "/" when returnTo is missing', async () => {
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(LoginPage);
await submit();
await waitFor(() => {
expect(goto).toHaveBeenCalledWith('/', { replaceState: true });
});
});
test.each([
['//evil.com', 'protocol-relative'],
['http://evil.com', 'absolute URL'],
['/login', 'self-redirect'],
['../admin', 'parent traversal']
])('rejects unsafe returnTo %s (%s) and falls back to "/"', async (value) => {
state.pageUrl = new URL(`http://localhost/login?returnTo=${encodeURIComponent(value)}`);
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(LoginPage);
await submit();
await waitFor(() => {
expect(goto).toHaveBeenCalledWith('/', { replaceState: true });
});
});
test('submit button disables and marks aria-busy while pending', async () => {
let release!: () => void;
(login as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise<void>((resolve) => { release = () => resolve(); })
);
render(LoginPage);
await submit();
const btn = screen.getByRole('button', { name: /sign in/i });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute('aria-busy', 'true');
release();
await waitFor(() => expect(btn).not.toBeDisabled());
});
});