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>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user