test(web): add component tests for login validation (#28562)

Test empty email, invalid email (no @), empty password,
and successful valid submission.
pull/28716/head
Alexander Chen 2026-05-30 19:54:06 -07:00
parent 7f84ecad9a
commit 39b21775ba
No known key found for this signature in database
GPG Key ID: 15A4A9BF7E21E170
3 changed files with 239 additions and 0 deletions

BIN
.github/repro/login-validation-repro.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Validation Reproduction — #28562</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 2rem; background: #f5f5f5; }
.scenario { background: white; border: 2px solid #333; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; max-width: 500px; }
.label { display: inline-block; background: #333; color: white; padding: 4px 8px; font-size: 12px; font-weight: bold; margin-bottom: 1rem; }
input { display: block; width: 100%; padding: 8px; margin: 8px 0; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
button { padding: 10px 16px; cursor: pointer; margin-top: 8px; }
.toast { padding: 10px; border-radius: 6px; margin-top: 12px; font-size: 13px; display: none; }
.toast.show { display: block; }
.toast.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.toast.success { background: #dcfce7; color: #166534; border: 1px solid #86efac; }
.bad { border-left: 4px solid #ef4444; }
.good { border-left: 4px solid #22c55e; }
</style>
</head>
<body>
<h2>Reproduction: Login Form Silent Failure (#28562)</h2>
<p>Simulates the login form behavior on Firefox (non-HTTPS) where HTML5 validation is suppressed.</p>
<div class="scenario bad">
<div class="label">BEFORE — No client-side validation</div>
<p>HTML5 validation is suppressed. The form does nothing and the user gets zero feedback.</p>
<input type="email" id="email-before" placeholder="Email" value="notanemail">
<input type="password" id="password-before" placeholder="Password" value="secret">
<button onclick="loginBefore()">Login</button>
<div class="toast error" id="toast-before"></div>
</div>
<div class="scenario good">
<div class="label">AFTER — Explicit client-side validation</div>
<p>Each invalid state shows a clear toast message before the API call is attempted.</p>
<input type="email" id="email-after" placeholder="Email" value="notanemail">
<input type="password" id="password-after" placeholder="Password" value="secret">
<button onclick="loginAfter()">Login</button>
<div class="toast error" id="toast-after"></div>
</div>
<script>
function showToast(id, msg, type) {
const el = document.getElementById(id);
el.textContent = msg;
el.className = 'toast show ' + (type || 'error');
}
function hideToast(id) {
document.getElementById(id).className = 'toast';
}
function loginBefore() {
const email = document.getElementById('email-before').value;
const password = document.getElementById('password-before').value;
hideToast('toast-before');
// BEFORE: no validation — just silently does nothing on invalid input
if (!email.includes('@')) {
// In the real app, this path just returns and the user sees nothing
// because Firefox hides the HTML5 validation tooltip on non-HTTPS
showToast('toast-before', 'No feedback — form silently does nothing (imagine no toast at all)', 'error');
return;
}
showToast('toast-before', 'Would submit…', 'success');
}
function loginAfter() {
const email = document.getElementById('email-after').value;
const password = document.getElementById('password-after').value;
hideToast('toast-after');
// AFTER: explicit validation with clear error messages
if (!email || email.trim() === '') {
showToast('toast-after', 'Email is required');
return;
}
if (!email.includes('@')) {
showToast('toast-after', 'Incorrect email or password');
return;
}
if (!password || password.trim() === '') {
showToast('toast-after', 'Password Required');
return;
}
showToast('toast-after', 'Would submit…', 'success');
}
</script>
</body>
</html>

View File

@ -0,0 +1,150 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { login } from '@immich/sdk';
import { vi } from 'vitest';
import LoginPage from './+page.svelte';
vi.mock('$app/navigation', () => ({
goto: vi.fn(),
}));
vi.mock('$lib/managers/server-config-manager.svelte', () => ({
serverConfigManager: {
value: {
isInitialized: true,
isOnboarded: true,
loginPageMessage: '',
oauthButtonText: 'OAuth',
},
loadServerConfig: vi.fn(),
},
}));
vi.mock('$lib/managers/feature-flags-manager.svelte', () => ({
featureFlagsManager: {
value: {
oauth: false,
passwordLogin: true,
oauthAutoLaunch: false,
},
loadFeatureFlags: vi.fn(),
init: vi.fn(),
},
}));
vi.mock('$lib/managers/event-manager.svelte', () => ({
eventManager: {
emit: vi.fn(),
},
}));
vi.mock('$lib/managers/auth-manager.svelte', () => ({
authManager: {
isSharedLink: false,
},
}));
vi.mock('$app/stores', () => ({
page: {
subscribe: vi.fn((cb: (value: unknown) => void) => {
cb({
url: new URL('http://localhost:2283/auth/login'),
params: {},
route: { id: 'auth/login' },
status: 200,
error: null,
data: {},
form: undefined,
state: {},
});
return () => {};
}),
},
}));
const getData = () => ({
meta: { title: 'Login' },
continueUrl: '/photos',
});
describe('Login page validation', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should show email required error when email is empty', async () => {
render(LoginPage, { props: { data: getData() } });
const passwordInput = screen.getByLabelText(/password/i);
await fireEvent.input(passwordInput, { target: { value: 'password123' } });
const submitButton = screen.getByRole('button', { name: /to_login/i });
await fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/email is required/i);
});
expect(login).not.toHaveBeenCalled();
});
it('should show error for email without @ symbol', async () => {
render(LoginPage, { props: { data: getData() } });
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
await fireEvent.input(emailInput, { target: { value: 'invalid-email' } });
await fireEvent.input(passwordInput, { target: { value: 'password123' } });
const submitButton = screen.getByRole('button', { name: /to_login/i });
await fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/incorrect email or password/i);
});
expect(login).not.toHaveBeenCalled();
});
it('should show password required error when password is empty', async () => {
render(LoginPage, { props: { data: getData() } });
const emailInput = screen.getByLabelText(/email/i);
await fireEvent.input(emailInput, { target: { value: 'user@example.com' } });
const submitButton = screen.getByRole('button', { name: /to_login/i });
await fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/password required/i);
});
expect(login).not.toHaveBeenCalled();
});
it('should call login API when both email and password are valid', async () => {
vi.mocked(login).mockResolvedValueOnce({
id: 'user-id',
email: 'user@example.com',
name: 'Test User',
isAdmin: false,
shouldChangePassword: false,
isOnboarded: true,
} as never);
render(LoginPage, { props: { data: getData() } });
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
await fireEvent.input(emailInput, { target: { value: 'user@example.com' } });
await fireEvent.input(passwordInput, { target: { value: 'password123' } });
const submitButton = screen.getByRole('button', { name: /to_login/i });
await fireEvent.click(submitButton);
await waitFor(() => {
expect(login).toHaveBeenCalledWith({
loginCredentialDto: { email: 'user@example.com', password: 'password123' },
});
});
});
});