test(client): expand frontend test suite to 69.1% coverage

Add and extend tests across 32 files (+10 595 lines) covering Admin
panels (AuditLog, Backup, DevNotifications, GitHub), Collab (Chat,
Notes, Panel, Polls), Planner (DayDetailPanel, DayPlanSidebar),
Settings (DisplaySettings, Integrations, MapSettings), Files
(FileManager, FilesPage), Map, Layout (DemoBanner,
InAppNotificationBell), shared pickers (CustomDateTimePicker,
CustomTimePicker), Vacay holidays, pages (Dashboard, Login), unit
stores (authStore, inAppNotificationStore), API (authUrl, client
integration), and i18n. Also updates sonar-project.properties and
MSW trip handlers to support the new cases.
This commit is contained in:
jubnl
2026-04-07 21:55:41 +02:00
parent 9390a2e9c6
commit fd48169219
32 changed files with 10595 additions and 15 deletions
+345 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '../../tests/helpers/render';
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
@@ -243,4 +243,348 @@ describe('LoginPage', () => {
});
});
});
describe('FE-PAGE-LOGIN-011: Password change step appears when must_change_password', () => {
it('transitions to change password form when login returns must_change_password=true', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', must_change_password: true },
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('New password')).toBeInTheDocument();
});
expect(screen.getByPlaceholderText('Confirm new password')).toBeInTheDocument();
});
});
describe('FE-PAGE-LOGIN-012: Password change form validates length', () => {
it('shows error when new password is shorter than 8 characters', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', must_change_password: true },
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('New password')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('New password'), 'short');
await user.type(screen.getByPlaceholderText('Confirm new password'), 'short');
await user.click(screen.getByRole('button', { name: /update password/i }));
await waitFor(() => {
expect(screen.getByText(/at least 8/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-013: Password change form validates mismatch', () => {
it('shows error when new passwords do not match', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', must_change_password: true },
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('New password')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('New password'), 'newpassword123');
await user.type(screen.getByPlaceholderText('Confirm new password'), 'differentpassword123');
await user.click(screen.getByRole('button', { name: /update password/i }));
await waitFor(() => {
expect(screen.getByText(/do not match/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-014: Password change success navigates', () => {
it('shows takeoff overlay after successful password change', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', must_change_password: true },
});
}),
http.put('/api/auth/me/password', () => {
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('New password')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('New password'), 'newpassword123');
await user.type(screen.getByPlaceholderText('Confirm new password'), 'newpassword123');
await user.click(screen.getByRole('button', { name: /update password/i }));
await waitFor(() => {
expect(document.querySelector('.takeoff-overlay')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-015: First-setup mode switches to register when has_users=false', () => {
it('shows register form automatically when has_users is false', async () => {
server.use(
http.get('/api/auth/app-config', () => {
return HttpResponse.json({
has_users: false,
allow_registration: true,
demo_mode: false,
oidc_configured: false,
oidc_only_mode: false,
setup_complete: true,
});
}),
);
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText('admin')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-016: Registration disabled hides register option', () => {
it('does not show register button when allow_registration is false', async () => {
server.use(
http.get('/api/auth/app-config', () => {
return HttpResponse.json({
has_users: true,
allow_registration: false,
demo_mode: false,
oidc_configured: false,
oidc_only_mode: false,
setup_complete: true,
});
}),
);
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /^register$/i })).toBeNull();
});
});
describe('FE-PAGE-LOGIN-017: OIDC-only mode hides standard login form', () => {
it('does not render email/password inputs in oidc_only_mode', async () => {
server.use(
http.get('/api/auth/app-config', () => {
return HttpResponse.json({
has_users: true,
allow_registration: false,
demo_mode: false,
oidc_configured: true,
oidc_only_mode: true,
setup_complete: true,
});
}),
);
// Pass noRedirect via location.state to prevent window.location.href redirect
render(<LoginPage />, {
initialEntries: [{ pathname: '/login', state: { noRedirect: true } }],
});
await waitFor(() => {
expect(screen.queryByPlaceholderText(EMAIL_PLACEHOLDER)).toBeNull();
expect(screen.queryByPlaceholderText(PASSWORD_PLACEHOLDER)).toBeNull();
});
});
});
describe('FE-PAGE-LOGIN-018: MFA code submission completes login', () => {
it('shows takeoff overlay after successful MFA verification', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
mfa_required: true,
mfa_token: 'test-mfa-token-abc',
});
}),
http.post('/api/auth/mfa/verify-login', () => {
return HttpResponse.json({
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' },
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('000000 or XXXX-XXXX')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('000000 or XXXX-XXXX'), '123456');
await user.click(screen.getByRole('button', { name: /verify/i }));
await waitFor(() => {
expect(document.querySelector('.takeoff-overlay')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-019: Empty MFA code shows error', () => {
it('shows error when MFA code is empty and does not show takeoff overlay', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
mfa_required: true,
mfa_token: 'test-mfa-token-abc',
});
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('000000 or XXXX-XXXX')).toBeInTheDocument();
});
// Submit the form directly (bypasses browser constraint validation on required field)
const form = document.querySelector('form')!;
fireEvent.submit(form);
await waitFor(() => {
expect(screen.getByText(/enter the code from your authenticator/i)).toBeInTheDocument();
});
expect(document.querySelector('.takeoff-overlay')).toBeNull();
});
});
describe('FE-PAGE-LOGIN-020: Register form validates password length', () => {
it('shows error when registration password is shorter than 8 characters', async () => {
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /^register$/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /^register$/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText('admin')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('admin'), 'newuser');
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'new@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'short');
await user.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(screen.getByText(/at least 8/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-LOGIN-021: Invite token pre-fills register mode', () => {
it('renders register form when invite query param is present', async () => {
server.use(
http.get('/api/auth/invite/:token', () => {
return HttpResponse.json({ valid: true });
}),
);
// Simulate ?invite=abc123 by replacing window.location.search
const originalSearch = window.location.search;
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { ...window.location, search: '?invite=abc123' },
});
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText('admin')).toBeInTheDocument();
});
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { ...window.location, search: originalSearch },
});
});
});
});