mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
test(front): add test suite frontend (WIP)
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user