test(front): add test suite frontend (WIP)

This commit is contained in:
jubnl
2026-04-07 12:31:09 +02:00
parent 96080e8a03
commit 3c31902885
97 changed files with 16973 additions and 4 deletions
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../helpers/msw/server';
import { useAddonStore } from '../../../src/store/addonStore';
import { resetAllStores } from '../../helpers/store';
beforeEach(() => {
resetAllStores();
});
describe('addonStore', () => {
describe('FE-ADDON-001: loadAddons()', () => {
it('fetches and stores enabled addons', async () => {
await useAddonStore.getState().loadAddons();
const state = useAddonStore.getState();
expect(state.loaded).toBe(true);
expect(state.addons.length).toBeGreaterThan(0);
expect(state.addons[0]).toHaveProperty('id');
expect(state.addons[0]).toHaveProperty('enabled', true);
});
});
describe('FE-ADDON-002: isEnabled returns true for known addon', () => {
it('returns true when addon is in the list and enabled', async () => {
await useAddonStore.getState().loadAddons();
expect(useAddonStore.getState().isEnabled('vacay')).toBe(true);
});
});
describe('FE-ADDON-003: isEnabled returns false for unknown addon', () => {
it('returns false when addon is not in the list', async () => {
await useAddonStore.getState().loadAddons();
expect(useAddonStore.getState().isEnabled('nonexistent')).toBe(false);
});
});
describe('FE-ADDON-004: API failure', () => {
it('sets loaded: true and keeps addons empty on API error', async () => {
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })
)
);
await useAddonStore.getState().loadAddons();
const state = useAddonStore.getState();
expect(state.loaded).toBe(true);
expect(state.addons).toEqual([]);
});
});
});
+196
View File
@@ -0,0 +1,196 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../helpers/msw/server';
import { useAuthStore } from '../../../src/store/authStore';
import { resetAllStores } from '../../helpers/store';
import { buildUser } from '../../helpers/factories';
// The websocket module is already mocked globally in tests/setup.ts
import { connect, disconnect } from '../../../src/api/websocket';
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
});
describe('authStore', () => {
describe('FE-AUTH-001: Successful login', () => {
it('sets user, isAuthenticated: true, isLoading: false', async () => {
const user = buildUser();
server.use(
http.post('/api/auth/login', () =>
HttpResponse.json({ user, token: 'tok' })
)
);
await useAuthStore.getState().login(user.email, 'password');
const state = useAuthStore.getState();
expect(state.user).toEqual(user);
expect(state.isAuthenticated).toBe(true);
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
});
});
describe('FE-AUTH-002: Login failure', () => {
it('sets error and isAuthenticated: false', async () => {
server.use(
http.post('/api/auth/login', () =>
HttpResponse.json({ error: 'Bad credentials' }, { status: 401 })
)
);
await expect(
useAuthStore.getState().login('bad@example.com', 'wrong')
).rejects.toThrow();
const state = useAuthStore.getState();
expect(state.error).toBe('Bad credentials');
expect(state.isAuthenticated).toBe(false);
expect(state.isLoading).toBe(false);
});
});
describe('FE-AUTH-003: Login calls connect()', () => {
it('calls connect from websocket module after successful login', async () => {
const user = buildUser();
server.use(
http.post('/api/auth/login', () =>
HttpResponse.json({ user, token: 'tok' })
)
);
await useAuthStore.getState().login(user.email, 'password');
expect(connect).toHaveBeenCalledOnce();
});
});
describe('FE-AUTH-004: loadUser with valid session', () => {
it('sets user state from /auth/me', async () => {
const user = buildUser();
server.use(
http.get('/api/auth/me', () => HttpResponse.json({ user }))
);
await useAuthStore.getState().loadUser();
const state = useAuthStore.getState();
expect(state.user).toEqual(user);
expect(state.isAuthenticated).toBe(true);
expect(state.isLoading).toBe(false);
});
});
describe('FE-AUTH-005: loadUser with 401', () => {
it('clears auth state on 401', async () => {
server.use(
http.get('/api/auth/me', () =>
HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })
)
);
// Pre-seed as authenticated
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
await useAuthStore.getState().loadUser();
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.isLoading).toBe(false);
});
});
describe('FE-AUTH-006: logout', () => {
it('calls disconnect() and clears user state', () => {
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
useAuthStore.getState().logout();
const state = useAuthStore.getState();
expect(disconnect).toHaveBeenCalledOnce();
expect(state.user).toBeNull();
expect(state.isAuthenticated).toBe(false);
});
});
describe('FE-AUTH-007: Register success', () => {
it('sets user and authenticates', async () => {
const user = buildUser();
server.use(
http.post('/api/auth/register', () =>
HttpResponse.json({ user, token: 'tok' })
)
);
await useAuthStore.getState().register(user.username, user.email, 'password');
const state = useAuthStore.getState();
expect(state.user).toEqual(user);
expect(state.isAuthenticated).toBe(true);
expect(state.isLoading).toBe(false);
});
});
describe('FE-AUTH-008: authSequence guard', () => {
it('stale loadUser does not overwrite fresh login state', async () => {
let resolveStale!: (v: Response) => void;
const stalePromise = new Promise<Response>((res) => { resolveStale = res; });
// First call to /auth/me will hang until we resolve it manually
let callCount = 0;
server.use(
http.get('/api/auth/me', async () => {
callCount++;
if (callCount === 1) {
// Stale request — wait
await stalePromise;
return HttpResponse.json({ user: buildUser({ username: 'stale' }) });
}
// Should not be called a second time in this test
return HttpResponse.json({ user: buildUser({ username: 'fresh' }) });
})
);
// Start loadUser but don't await yet
const staleLoad = useAuthStore.getState().loadUser();
// Meanwhile, perform a login (bumps authSequence)
const freshUser = buildUser({ username: 'freshlogin' });
server.use(
http.post('/api/auth/login', () =>
HttpResponse.json({ user: freshUser, token: 'tok' })
)
);
await useAuthStore.getState().login(freshUser.email, 'password');
// Now resolve the stale loadUser response
resolveStale(new Response());
await staleLoad;
// The fresh login state must be preserved
const state = useAuthStore.getState();
expect(state.user?.username).toBe('freshlogin');
expect(state.isAuthenticated).toBe(true);
});
});
describe('FE-AUTH-009: MFA-required state handling', () => {
it('returns mfa_required flag and does not set user as authenticated', async () => {
server.use(
http.post('/api/auth/login', () =>
HttpResponse.json({ mfa_required: true, mfa_token: 'mfa-tok-123' })
)
);
const result = await useAuthStore.getState().login('user@example.com', 'password');
expect(result).toMatchObject({ mfa_required: true, mfa_token: 'mfa-tok-123' });
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(false);
expect(state.user).toBeNull();
});
});
});
@@ -0,0 +1,134 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../helpers/msw/server';
import { useInAppNotificationStore } from '../../../src/store/inAppNotificationStore';
import { resetAllStores } from '../../helpers/store';
// Raw notification factory matching the server shape (is_read as 0/1, params as strings)
function buildRawNotif(overrides: Record<string, unknown> = {}) {
const id = Math.floor(Math.random() * 100000);
return {
id,
type: 'simple',
scope: 'trip',
target: 1,
sender_id: 2,
sender_username: 'alice',
sender_avatar: null,
recipient_id: 1,
title_key: 'notif.title',
title_params: '{}',
text_key: 'notif.text',
text_params: '{}',
positive_text_key: null,
negative_text_key: null,
response: null,
navigate_text_key: null,
navigate_target: null,
is_read: 0,
created_at: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
beforeEach(() => {
resetAllStores();
});
describe('inAppNotificationStore', () => {
describe('FE-NOTIF-001: fetchNotifications() loads first page', () => {
it('populates notifications, total, and unreadCount', async () => {
await useInAppNotificationStore.getState().fetchNotifications();
const state = useInAppNotificationStore.getState();
expect(state.notifications.length).toBeGreaterThan(0);
expect(state.total).toBeGreaterThan(0);
expect(state.unreadCount).toBe(5);
expect(state.isLoading).toBe(false);
});
});
describe('FE-NOTIF-002: Pagination — loading more appends to list', () => {
it('appends additional notifications when fetchNotifications is called again', async () => {
// First page
await useInAppNotificationStore.getState().fetchNotifications(true);
const firstPageCount = useInAppNotificationStore.getState().notifications.length;
const total = useInAppNotificationStore.getState().total;
// Only test pagination if there are more items
if (firstPageCount < total) {
await useInAppNotificationStore.getState().fetchNotifications();
const state = useInAppNotificationStore.getState();
expect(state.notifications.length).toBeGreaterThan(firstPageCount);
} else {
// All notifications fit in one page
expect(firstPageCount).toBe(total);
}
});
});
describe('FE-NOTIF-003: markRead(id)', () => {
it('updates is_read to true for the notification', async () => {
// Seed with an unread notification
const unread = buildRawNotif({ id: 42, is_read: 0 });
useInAppNotificationStore.setState({
notifications: [{ ...unread, title_params: {}, text_params: {}, is_read: false }] as never,
unreadCount: 1,
});
await useInAppNotificationStore.getState().markRead(42);
const state = useInAppNotificationStore.getState();
const notif = state.notifications.find((n) => n.id === 42);
expect(notif?.is_read).toBe(true);
expect(state.unreadCount).toBe(0);
});
});
describe('FE-NOTIF-004: handleNewNotification() prepends to list', () => {
it('adds a new notification at the start of the list', () => {
// Seed existing notifications
useInAppNotificationStore.setState({
notifications: [{ ...buildRawNotif({ id: 1 }), title_params: {}, text_params: {}, is_read: false }] as never,
total: 1,
unreadCount: 1,
});
const newRaw = buildRawNotif({ id: 99 });
useInAppNotificationStore.getState().handleNewNotification(newRaw as never);
const state = useInAppNotificationStore.getState();
expect(state.notifications[0].id).toBe(99);
expect(state.notifications.length).toBe(2);
expect(state.total).toBe(2);
expect(state.unreadCount).toBe(2);
});
});
describe('FE-NOTIF-005: handleUpdatedNotification() updates existing notification', () => {
it('replaces the notification in the list', () => {
useInAppNotificationStore.setState({
notifications: [{ ...buildRawNotif({ id: 7, is_read: 0 }), title_params: {}, text_params: {}, is_read: false }] as never,
total: 1,
unreadCount: 1,
});
const updated = buildRawNotif({ id: 7, is_read: 1 });
useInAppNotificationStore.getState().handleUpdatedNotification(updated as never);
const state = useInAppNotificationStore.getState();
const notif = state.notifications.find((n) => n.id === 7);
expect(notif?.is_read).toBe(true);
});
});
describe('FE-NOTIF-006: Unread count is correct', () => {
it('unreadCount matches the number of unread notifications', async () => {
await useInAppNotificationStore.getState().fetchNotifications(true);
const state = useInAppNotificationStore.getState();
// The mock returns 5 unread from the server
expect(state.unreadCount).toBe(5);
});
});
});
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { usePermissionsStore, useCanDo } from '../../../src/store/permissionsStore';
import { useAuthStore } from '../../../src/store/authStore';
import { resetAllStores } from '../../helpers/store';
import { buildUser, buildAdmin } from '../../helpers/factories';
beforeEach(() => {
resetAllStores();
});
describe('permissionsStore', () => {
describe('FE-PERMS-001: setPermissions()', () => {
it('stores the permission map', () => {
const perms = { trip_create: 'everybody', file_upload: 'trip_member' } as const;
usePermissionsStore.getState().setPermissions(perms);
expect(usePermissionsStore.getState().permissions).toEqual(perms);
});
});
describe('FE-PERMS-002: useCanDo() — basic allow/deny', () => {
it('returns false when user is not authenticated', () => {
usePermissionsStore.getState().setPermissions({ trip_create: 'everybody' });
const { result } = renderHook(() => useCanDo());
expect(result.current('trip_create')).toBe(false);
});
it('returns true for "everybody" when user is authenticated', () => {
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
usePermissionsStore.getState().setPermissions({ trip_create: 'everybody' });
const { result } = renderHook(() => useCanDo());
expect(result.current('trip_create')).toBe(true);
});
it('returns true when action has no configured permission (default allow)', () => {
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
usePermissionsStore.getState().setPermissions({});
const { result } = renderHook(() => useCanDo());
expect(result.current('unconfigured_action')).toBe(true);
});
});
describe('Admin user', () => {
it('can do anything regardless of configured permissions', () => {
useAuthStore.setState({ user: buildAdmin(), isAuthenticated: true });
usePermissionsStore.getState().setPermissions({ restricted_action: 'admin' });
const { result } = renderHook(() => useCanDo());
expect(result.current('restricted_action')).toBe(true);
});
});
describe('Owner permissions', () => {
it('trip_owner level: owner can act, member cannot', () => {
const user = buildUser({ id: 42 });
useAuthStore.setState({ user, isAuthenticated: true });
usePermissionsStore.getState().setPermissions({ delete_trip: 'trip_owner' });
const { result } = renderHook(() => useCanDo());
const trip = { owner_id: 42 }; // user is owner
const otherTrip = { owner_id: 99 }; // user is not owner
expect(result.current('delete_trip', trip)).toBe(true);
expect(result.current('delete_trip', otherTrip)).toBe(false);
});
it('trip_owner level: is_owner flag grants access', () => {
const user = buildUser({ id: 1 });
useAuthStore.setState({ user, isAuthenticated: true });
usePermissionsStore.getState().setPermissions({ delete_trip: 'trip_owner' });
const { result } = renderHook(() => useCanDo());
expect(result.current('delete_trip', { is_owner: true })).toBe(true);
expect(result.current('delete_trip', { is_owner: false })).toBe(false);
});
});
describe('Member permissions', () => {
it('trip_member level: members and owners can act, unauthenticated trip context cannot', () => {
const user = buildUser({ id: 1 });
useAuthStore.setState({ user, isAuthenticated: true });
usePermissionsStore.getState().setPermissions({ upload_file: 'trip_member' });
const { result } = renderHook(() => useCanDo());
const asOwner = { owner_id: 1 }; // user is owner
const asMember = { owner_id: 99 }; // user is member (trip context provided, not owner)
const noTrip = null; // no trip context
expect(result.current('upload_file', asOwner)).toBe(true);
expect(result.current('upload_file', asMember)).toBe(true);
expect(result.current('upload_file', noTrip)).toBe(false);
});
});
describe('Nobody / admin-only level', () => {
it('admin level: regular user is denied even as trip owner', () => {
const user = buildUser({ id: 1 });
useAuthStore.setState({ user, isAuthenticated: true });
usePermissionsStore.getState().setPermissions({ admin_action: 'admin' });
const { result } = renderHook(() => useCanDo());
expect(result.current('admin_action', { owner_id: 1 })).toBe(false);
expect(result.current('admin_action')).toBe(false);
});
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../helpers/msw/server';
import { useSettingsStore } from '../../../src/store/settingsStore';
import { resetAllStores } from '../../helpers/store';
import { buildSettings } from '../../helpers/factories';
beforeEach(() => {
resetAllStores();
});
describe('settingsStore', () => {
describe('FE-SETTINGS-001: loadSettings()', () => {
it('fetches settings and updates store', async () => {
const settings = buildSettings({ default_currency: 'EUR', language: 'de' });
server.use(
http.get('/api/settings', () => HttpResponse.json({ settings }))
);
await useSettingsStore.getState().loadSettings();
const state = useSettingsStore.getState();
expect(state.settings.default_currency).toBe('EUR');
expect(state.settings.language).toBe('de');
expect(state.isLoaded).toBe(true);
});
});
describe('FE-SETTINGS-002: updateSetting() optimistic update', () => {
it('immediately updates local state before API resolves', async () => {
// The store's set() is called synchronously before the first await (settingsApi.set)
// so state is visible without needing to await the full action.
const promise = useSettingsStore.getState().updateSetting('default_currency', 'GBP');
// Check optimistic state — no await needed here
expect(useSettingsStore.getState().settings.default_currency).toBe('GBP');
// Let the API call finish to avoid dangling promises
await promise;
});
});
describe('FE-SETTINGS-003: updateSetting() reverts on API failure', () => {
it('throws when API fails', async () => {
server.use(
http.put('/api/settings', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })
)
);
// The store optimistically sets, then throws — the revert is a throw
await expect(
useSettingsStore.getState().updateSetting('default_currency', 'GBP')
).rejects.toThrow();
});
});
describe('FE-SETTINGS-004: Language change', () => {
it('updates language field and localStorage', async () => {
await useSettingsStore.getState().updateSetting('language', 'fr');
const state = useSettingsStore.getState();
expect(state.settings.language).toBe('fr');
expect(localStorage.getItem('app_language')).toBe('fr');
});
});
describe('FE-SETTINGS-005: loadSettings failure', () => {
it('sets isLoaded: true even on API failure (graceful)', async () => {
server.use(
http.get('/api/settings', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })
)
);
await useSettingsStore.getState().loadSettings();
const state = useSettingsStore.getState();
expect(state.isLoaded).toBe(true);
});
});
});
+148
View File
@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../helpers/msw/server';
import { useVacayStore } from '../../../src/store/vacayStore';
import { resetAllStores } from '../../helpers/store';
beforeEach(() => {
resetAllStores();
});
describe('vacayStore', () => {
describe('FE-VACAY-001: loadAll()', () => {
it('fetches plan, years, entries, and stats, updates state', async () => {
await useVacayStore.getState().loadAll();
const state = useVacayStore.getState();
expect(state.plan).not.toBeNull();
expect(state.plan?.id).toBe(1);
expect(state.years).toEqual([2025, 2026]);
expect(state.entries.length).toBeGreaterThan(0);
expect(state.stats.length).toBeGreaterThan(0);
expect(state.loading).toBe(false);
});
});
describe('FE-VACAY-002: toggleEntry()', () => {
it('calls the toggle API then reloads entries and stats', async () => {
// Seed selected year
useVacayStore.setState({ selectedYear: 2025 });
let toggled = false;
server.use(
http.post('/api/addons/vacay/entries/toggle', () => {
toggled = true;
return HttpResponse.json({ success: true });
})
);
await useVacayStore.getState().toggleEntry('2025-06-20');
expect(toggled).toBe(true);
// After toggle, entries are refreshed from MSW (2 entries)
expect(useVacayStore.getState().entries.length).toBe(2);
});
});
describe('FE-VACAY-003: loadHolidays() — holidays_enabled with calendars', () => {
it('populates holidays map when plan has holiday calendars', async () => {
// Set plan state with holidays_enabled and a simple (non-regional) calendar
useVacayStore.setState({
selectedYear: 2025,
plan: {
id: 1,
holidays_enabled: true,
holidays_region: null,
holiday_calendars: [
{ id: 1, plan_id: 1, region: 'DE', label: 'Germany', color: '#ef4444', sort_order: 0 },
],
block_weekends: true,
carry_over_enabled: false,
company_holidays_enabled: false,
},
});
// Override MSW to return non-regional holidays (no counties)
server.use(
http.get('/api/addons/vacay/holidays/:year/:country', () =>
HttpResponse.json([
{ date: '2025-12-25', name: 'Christmas', localName: 'Weihnachten', global: true, counties: null },
{ date: '2025-01-01', name: 'New Year', localName: 'Neujahr', global: true, counties: null },
])
)
);
await useVacayStore.getState().loadHolidays(2025);
const state = useVacayStore.getState();
expect(Object.keys(state.holidays).length).toBeGreaterThan(0);
expect(state.holidays['2025-12-25']).toBeDefined();
expect(state.holidays['2025-12-25'].name).toBe('Christmas');
});
});
describe('FE-VACAY-003b: loadHolidays() — holidays not enabled', () => {
it('sets holidays to empty map when holidays_enabled is false', async () => {
useVacayStore.setState({
selectedYear: 2025,
plan: {
id: 1,
holidays_enabled: false,
holidays_region: null,
holiday_calendars: [],
block_weekends: true,
carry_over_enabled: false,
company_holidays_enabled: false,
},
});
await useVacayStore.getState().loadHolidays(2025);
expect(useVacayStore.getState().holidays).toEqual({});
});
});
describe('FE-VACAY-004a: updatePlan()', () => {
it('updates plan and reloads entries, stats, holidays', async () => {
// Need existing plan for holiday check in loadHolidays
useVacayStore.setState({
selectedYear: 2025,
plan: {
id: 1,
holidays_enabled: false,
holidays_region: null,
holiday_calendars: [],
block_weekends: true,
carry_over_enabled: false,
company_holidays_enabled: false,
},
});
await useVacayStore.getState().updatePlan({ holidays_enabled: true });
const state = useVacayStore.getState();
// The MSW handler for PUT /addons/vacay/plan returns holidays_enabled: true
expect(state.plan?.holidays_enabled).toBe(true);
});
});
describe('FE-VACAY-004b: addYear()', () => {
it('adds a year and the years list is updated', async () => {
await useVacayStore.getState().addYear(2027);
expect(useVacayStore.getState().years).toContain(2027);
});
});
describe('FE-VACAY-004c: removeYear()', () => {
it('removes a year and updates the years list', async () => {
useVacayStore.setState({ years: [2025, 2026], selectedYear: 2026 });
await useVacayStore.getState().removeYear(2026);
const state = useVacayStore.getState();
// MSW returns [2025] after delete
expect(state.years).toEqual([2025]);
// selectedYear should shift to the last remaining year
expect(state.selectedYear).toBe(2025);
});
});
});