mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
test: expand frontend test suite to 82% coverage
Adds ~45 new and updated test files covering Admin, Collab, Dashboard, Map, Memories, PDF, Photos, Planner, Settings, Vacay, Weather components, pages, stores, and a WebSocket integration test.
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
// IMPORTANT: unmock must be the very first statement before any imports
|
||||
vi.unmock('../../../src/api/websocket');
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import {
|
||||
connect,
|
||||
disconnect,
|
||||
joinTrip,
|
||||
leaveTrip,
|
||||
addListener,
|
||||
removeListener,
|
||||
getSocketId,
|
||||
setRefetchCallback,
|
||||
} from '../../../src/api/websocket';
|
||||
|
||||
// ── Fake WebSocket ────────────────────────────────────────────────────────────
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
|
||||
readyState: number = MockWebSocket.OPEN;
|
||||
send = vi.fn();
|
||||
close = vi.fn();
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(public url: string) {
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
static instances: MockWebSocket[] = [];
|
||||
static reset() {
|
||||
MockWebSocket.instances = [];
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
MockWebSocket.reset();
|
||||
|
||||
// Replace globalThis.WebSocket with MockWebSocket directly.
|
||||
// jsdom marks WebSocket as non-writable, so we must use defineProperty.
|
||||
Object.defineProperty(globalThis, 'WebSocket', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockWebSocket,
|
||||
});
|
||||
|
||||
// Default handler: ws-token returns a valid token
|
||||
server.use(
|
||||
http.post('/api/auth/ws-token', () =>
|
||||
HttpResponse.json({ token: 'test-ws-token' })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disconnect();
|
||||
setRefetchCallback(null);
|
||||
vi.useRealTimers();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Helper to get the most recently created MockWebSocket instance
|
||||
function lastSocket(): MockWebSocket {
|
||||
return MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||
}
|
||||
|
||||
// ── connect / disconnect ──────────────────────────────────────────────────────
|
||||
|
||||
describe('connect / disconnect', () => {
|
||||
it('FE-COMP-WS-001: connect() fetches ws-token and creates a WebSocket with it', async () => {
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0].url).toContain('token=test-ws-token');
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-002: connect() sets shouldReconnect so onclose triggers reconnect', async () => {
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
|
||||
// Simulate socket close (triggers scheduleReconnect)
|
||||
lastSocket().onclose!();
|
||||
|
||||
// Advance past initial reconnect delay (1000ms) — reconnect fires
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-003: disconnect() prevents reconnect after socket close', async () => {
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const sock = lastSocket();
|
||||
disconnect();
|
||||
|
||||
// After disconnect, onclose is nulled — simulating close should be safe
|
||||
// but we also fire it manually to be sure
|
||||
if (sock.onclose) sock.onclose();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Still only the original socket
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-004: connect() is idempotent — calling twice creates only one socket', async () => {
|
||||
connect();
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ws-token fetch failures ───────────────────────────────────────────────────
|
||||
|
||||
describe('ws-token fetch failures', () => {
|
||||
it('FE-COMP-WS-005: 401 on ws-token fetch stops reconnect entirely', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/ws-token', () =>
|
||||
new HttpResponse(null, { status: 401 })
|
||||
)
|
||||
);
|
||||
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// No socket should be created
|
||||
expect(MockWebSocket.instances).toHaveLength(0);
|
||||
|
||||
// Advance timers — no retry should fire
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-006: non-401 error on ws-token schedules a reconnect', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/ws-token', () =>
|
||||
new HttpResponse(null, { status: 503 })
|
||||
)
|
||||
);
|
||||
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// No socket yet
|
||||
expect(MockWebSocket.instances).toHaveLength(0);
|
||||
|
||||
// Now allow the next fetch to succeed
|
||||
server.use(
|
||||
http.post('/api/auth/ws-token', () =>
|
||||
HttpResponse.json({ token: 'retry-token' })
|
||||
)
|
||||
);
|
||||
|
||||
// Advance past initial reconnect delay
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// A socket should now be created
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── onopen / join on reconnect ────────────────────────────────────────────────
|
||||
|
||||
describe('onopen / join on reconnect', () => {
|
||||
it('FE-COMP-WS-007: onopen sends join messages for all active trips', async () => {
|
||||
joinTrip(42);
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const sock = lastSocket();
|
||||
sock.onopen!();
|
||||
|
||||
expect(sock.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'join', tripId: '42' })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-008: onopen invokes refetchCallback for each active trip', async () => {
|
||||
const refetch = vi.fn();
|
||||
setRefetchCallback(refetch);
|
||||
joinTrip(1);
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
lastSocket().onopen!();
|
||||
|
||||
expect(refetch).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── joinTrip / leaveTrip ──────────────────────────────────────────────────────
|
||||
|
||||
describe('joinTrip / leaveTrip', () => {
|
||||
it('FE-COMP-WS-009: joinTrip sends join message immediately when socket is open', async () => {
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const sock = lastSocket();
|
||||
sock.onopen!();
|
||||
|
||||
joinTrip(99);
|
||||
|
||||
expect(sock.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'join', tripId: '99' })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-010: joinTrip queues trip when socket is not open yet', async () => {
|
||||
joinTrip(5);
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const sock = lastSocket();
|
||||
sock.onopen!();
|
||||
|
||||
expect(sock.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'join', tripId: '5' })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-011: leaveTrip sends leave message and removes from activeTrips', async () => {
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const sock = lastSocket();
|
||||
sock.onopen!();
|
||||
|
||||
joinTrip(7);
|
||||
leaveTrip(7);
|
||||
|
||||
expect(sock.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'leave', tripId: '7' })
|
||||
);
|
||||
|
||||
// Simulate close + reconnect — trip 7 should NOT be re-joined
|
||||
sock.onclose!();
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const sock2 = lastSocket();
|
||||
sock2.onopen!();
|
||||
|
||||
// send called for initial join (trip 7) but not after leaveTrip
|
||||
const joinCalls = sock2.send.mock.calls.filter(
|
||||
c => JSON.parse(c[0]).tripId === '7'
|
||||
);
|
||||
expect(joinCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleMessage / listeners ─────────────────────────────────────────────────
|
||||
|
||||
describe('handleMessage / listeners', () => {
|
||||
async function setupConnectedSocket() {
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const sock = lastSocket();
|
||||
sock.onopen!();
|
||||
return sock;
|
||||
}
|
||||
|
||||
it('FE-COMP-WS-012: welcome message sets socketId and is NOT dispatched to listeners', async () => {
|
||||
const sock = await setupConnectedSocket();
|
||||
const listener = vi.fn();
|
||||
addListener(listener);
|
||||
|
||||
sock.onmessage!({ data: JSON.stringify({ type: 'welcome', socketId: 'server-sid-1' }) });
|
||||
|
||||
expect(getSocketId()).toBe('server-sid-1');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
removeListener(listener);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-013: non-welcome messages are dispatched to all registered listeners', async () => {
|
||||
const sock = await setupConnectedSocket();
|
||||
const l1 = vi.fn();
|
||||
const l2 = vi.fn();
|
||||
addListener(l1);
|
||||
addListener(l2);
|
||||
|
||||
const msg = { type: 'place_added', tripId: '1' };
|
||||
sock.onmessage!({ data: JSON.stringify(msg) });
|
||||
|
||||
expect(l1).toHaveBeenCalledWith(msg);
|
||||
expect(l2).toHaveBeenCalledWith(msg);
|
||||
|
||||
removeListener(l1);
|
||||
removeListener(l2);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-014: listener error is caught and does not prevent other listeners from firing', async () => {
|
||||
const sock = await setupConnectedSocket();
|
||||
const throwing = vi.fn().mockImplementation(() => { throw new Error('boom'); });
|
||||
const working = vi.fn();
|
||||
addListener(throwing);
|
||||
addListener(working);
|
||||
|
||||
expect(() => {
|
||||
sock.onmessage!({ data: JSON.stringify({ type: 'some_event' }) });
|
||||
}).not.toThrow();
|
||||
|
||||
expect(working).toHaveBeenCalled();
|
||||
|
||||
removeListener(throwing);
|
||||
removeListener(working);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-015: malformed JSON in message is caught silently', async () => {
|
||||
const sock = await setupConnectedSocket();
|
||||
const listener = vi.fn();
|
||||
addListener(listener);
|
||||
|
||||
expect(() => {
|
||||
sock.onmessage!({ data: 'not-json' });
|
||||
}).not.toThrow();
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
removeListener(listener);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-016: removeListener stops a listener from receiving messages', async () => {
|
||||
const sock = await setupConnectedSocket();
|
||||
const listener = vi.fn();
|
||||
addListener(listener);
|
||||
removeListener(listener);
|
||||
|
||||
sock.onmessage!({ data: JSON.stringify({ type: 'update' }) });
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── addListener / removeListener ─────────────────────────────────────────────
|
||||
|
||||
describe('addListener / removeListener symmetry', () => {
|
||||
it('FE-COMP-WS-017: listener set grows and shrinks correctly', async () => {
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const sock = lastSocket();
|
||||
sock.onopen!();
|
||||
|
||||
const l1 = vi.fn();
|
||||
const l2 = vi.fn();
|
||||
addListener(l1);
|
||||
addListener(l2);
|
||||
|
||||
sock.onmessage!({ data: JSON.stringify({ type: 'ping' }) });
|
||||
expect(l1).toHaveBeenCalledTimes(1);
|
||||
expect(l2).toHaveBeenCalledTimes(1);
|
||||
|
||||
removeListener(l1);
|
||||
|
||||
sock.onmessage!({ data: JSON.stringify({ type: 'ping' }) });
|
||||
expect(l1).toHaveBeenCalledTimes(1); // no new calls
|
||||
expect(l2).toHaveBeenCalledTimes(2);
|
||||
|
||||
removeListener(l2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSocketId / setRefetchCallback ─────────────────────────────────────────
|
||||
|
||||
describe('getSocketId / setRefetchCallback', () => {
|
||||
it('FE-COMP-WS-018: getSocketId() returns null before welcome message', async () => {
|
||||
// mySocketId is a module-level singleton that persists between tests.
|
||||
// Use vi.resetModules() + dynamic import to get a fresh module state.
|
||||
vi.resetModules();
|
||||
const freshWs = await import('../../../src/api/websocket');
|
||||
expect(freshWs.getSocketId()).toBeNull();
|
||||
// Clean up: restore the real module for subsequent tests by resetting again
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-019: setRefetchCallback(null) clears the callback', async () => {
|
||||
const cb = vi.fn();
|
||||
setRefetchCallback(cb);
|
||||
setRefetchCallback(null);
|
||||
|
||||
joinTrip(10);
|
||||
connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
lastSocket().onopen!();
|
||||
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reconnect backoff ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('reconnect backoff', () => {
|
||||
it('FE-COMP-WS-020: reconnect delay doubles on each failure up to 30s max', async () => {
|
||||
// Make every fetch fail with 503 so reconnect keeps firing
|
||||
server.use(
|
||||
http.post('/api/auth/ws-token', () =>
|
||||
new HttpResponse(null, { status: 503 })
|
||||
)
|
||||
);
|
||||
|
||||
connect();
|
||||
|
||||
const delays = [1000, 2000, 4000, 8000, 16000, 30000];
|
||||
let totalAdvanced = 0;
|
||||
|
||||
for (const delay of delays) {
|
||||
// Wait for the fetch to complete
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
// No socket should ever be created
|
||||
expect(MockWebSocket.instances).toHaveLength(0);
|
||||
// Advance to trigger next reconnect
|
||||
await vi.advanceTimersByTimeAsync(delay + 1);
|
||||
totalAdvanced += delay + 1;
|
||||
}
|
||||
|
||||
// After advancing through all delays, still no socket
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(MockWebSocket.instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Module-level types for dynamic imports
|
||||
type PhotoServiceModule = typeof import('../../../src/services/photoService');
|
||||
type ApiClientModule = typeof import('../../../src/api/client');
|
||||
|
||||
let svc: PhotoServiceModule;
|
||||
let mockPlacePhoto: ReturnType<typeof vi.fn>;
|
||||
|
||||
// ── Canvas mock helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function setupCanvasMock(dataUrl = 'data:image/webp;base64,mock') {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(dataUrl);
|
||||
}
|
||||
|
||||
// ── Image src interceptor ──────────────────────────────────────────────────────
|
||||
// jsdom doesn't load images; we override the src setter so onload/onerror fire.
|
||||
|
||||
function setupImageAutoLoad(succeed = true) {
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'src', {
|
||||
configurable: true,
|
||||
set(url: string) {
|
||||
(this as HTMLImageElement & { _src: string })._src = url;
|
||||
// Fire asynchronously so assignment completes before handler runs
|
||||
Promise.resolve().then(() => {
|
||||
if (succeed && typeof this.onload === 'function') {
|
||||
this.onload(new Event('load'));
|
||||
} else if (!succeed && typeof this.onerror === 'function') {
|
||||
this.onerror(new Event('error'));
|
||||
}
|
||||
});
|
||||
},
|
||||
get() {
|
||||
return (this as HTMLImageElement & { _src: string })._src ?? '';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function restoreImageSrc() {
|
||||
// Remove override — jsdom's descriptor is on the prototype, restoring
|
||||
// configurable property to original (no-op src) is sufficient for test isolation.
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'src', {
|
||||
configurable: true,
|
||||
set(_url: string) {},
|
||||
get() { return ''; },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Module reset helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function freshImports() {
|
||||
vi.resetModules();
|
||||
vi.doMock('../../../src/api/client', () => ({
|
||||
mapsApi: { placePhoto: vi.fn() },
|
||||
}));
|
||||
svc = await import('../../../src/services/photoService');
|
||||
const apiClient = await import('../../../src/api/client') as ApiClientModule;
|
||||
mockPlacePhoto = vi.mocked(apiClient.mapsApi.placePhoto);
|
||||
}
|
||||
|
||||
// ── Flush all pending microtasks + macrotasks ──────────────────────────────────
|
||||
const flush = () => new Promise<void>(r => setTimeout(r, 0));
|
||||
|
||||
// ==============================================================================
|
||||
|
||||
beforeEach(async () => {
|
||||
await freshImports();
|
||||
setupCanvasMock();
|
||||
setupImageAutoLoad(true); // default: image loads succeed so urlToBase64 resolves and .finally() runs
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
restoreImageSrc();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// getCached / isLoading
|
||||
// ==============================================================================
|
||||
|
||||
describe('getCached', () => {
|
||||
it('FE-COMP-PHOTO-001: returns undefined for an unknown key', () => {
|
||||
expect(svc.getCached('missing')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLoading', () => {
|
||||
it('FE-COMP-PHOTO-002: returns false before any fetch', () => {
|
||||
expect(svc.isLoading('key')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// fetchPhoto — cache hit
|
||||
// ==============================================================================
|
||||
|
||||
describe('fetchPhoto — cache hit', () => {
|
||||
it('FE-COMP-PHOTO-003: callback fires immediately on second call; API called only once', async () => {
|
||||
mockPlacePhoto.mockResolvedValue({ photoUrl: 'https://example.com/photo.jpg' });
|
||||
|
||||
const cb1 = vi.fn();
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb1);
|
||||
await flush();
|
||||
|
||||
expect(mockPlacePhoto).toHaveBeenCalledTimes(1);
|
||||
expect(cb1).toHaveBeenCalledWith(expect.objectContaining({ photoUrl: 'https://example.com/photo.jpg' }));
|
||||
|
||||
const cb2 = vi.fn();
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb2);
|
||||
// Cache hit → synchronous call, no additional API request
|
||||
expect(cb2).toHaveBeenCalledWith(expect.objectContaining({ photoUrl: 'https://example.com/photo.jpg' }));
|
||||
expect(mockPlacePhoto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// fetchPhoto — in-flight deduplication
|
||||
// ==============================================================================
|
||||
|
||||
describe('fetchPhoto — in-flight deduplication', () => {
|
||||
it('FE-COMP-PHOTO-004: concurrent calls make only one API request; both callbacks receive result', async () => {
|
||||
let resolve!: (v: { photoUrl: string }) => void;
|
||||
mockPlacePhoto.mockReturnValue(new Promise<{ photoUrl: string }>(r => { resolve = r; }));
|
||||
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb1);
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb2);
|
||||
|
||||
expect(mockPlacePhoto).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolve({ photoUrl: 'https://example.com/photo.jpg' });
|
||||
await flush();
|
||||
|
||||
expect(cb1).toHaveBeenCalledWith(expect.objectContaining({ photoUrl: 'https://example.com/photo.jpg' }));
|
||||
expect(cb2).toHaveBeenCalledWith(expect.objectContaining({ photoUrl: 'https://example.com/photo.jpg' }));
|
||||
});
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// fetchPhoto — photoUrl present
|
||||
// ==============================================================================
|
||||
|
||||
describe('fetchPhoto — photoUrl present', () => {
|
||||
it('FE-COMP-PHOTO-005: callback receives entry with photoUrl set and thumbDataUrl null at call time', async () => {
|
||||
mockPlacePhoto.mockResolvedValue({ photoUrl: 'https://example.com/photo.jpg' });
|
||||
|
||||
// Capture a shallow clone at the moment of the call, before the entry is mutated by thumb generation
|
||||
const snapshots: { photoUrl: string | null; thumbDataUrl: string | null }[] = [];
|
||||
const cb = vi.fn((entry: { photoUrl: string | null; thumbDataUrl: string | null }) => {
|
||||
snapshots.push({ ...entry });
|
||||
});
|
||||
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb);
|
||||
await flush();
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(snapshots[0]).toEqual({ photoUrl: 'https://example.com/photo.jpg', thumbDataUrl: null });
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTO-006: getCached returns the entry after fetch resolves', async () => {
|
||||
mockPlacePhoto.mockResolvedValue({ photoUrl: 'https://example.com/photo.jpg' });
|
||||
|
||||
svc.fetchPhoto('k', 'pid');
|
||||
await flush();
|
||||
|
||||
const entry = svc.getCached('k');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.photoUrl).toBe('https://example.com/photo.jpg');
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTO-007: isLoading returns false after fetch completes', async () => {
|
||||
mockPlacePhoto.mockResolvedValue({ photoUrl: 'https://example.com/photo.jpg' });
|
||||
|
||||
svc.fetchPhoto('k', 'pid');
|
||||
await flush();
|
||||
|
||||
expect(svc.isLoading('k')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// fetchPhoto — photoUrl null
|
||||
// ==============================================================================
|
||||
|
||||
describe('fetchPhoto — photoUrl null', () => {
|
||||
it('FE-COMP-PHOTO-008: callback receives null entry when API returns no photoUrl', async () => {
|
||||
mockPlacePhoto.mockResolvedValue({});
|
||||
|
||||
const cb = vi.fn();
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb);
|
||||
await flush();
|
||||
|
||||
expect(cb).toHaveBeenCalledWith({ photoUrl: null, thumbDataUrl: null });
|
||||
expect(svc.getCached('k')).toEqual({ photoUrl: null, thumbDataUrl: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// fetchPhoto — API error
|
||||
// ==============================================================================
|
||||
|
||||
describe('fetchPhoto — API error', () => {
|
||||
it('FE-COMP-PHOTO-009: callback receives null entry on API rejection', async () => {
|
||||
mockPlacePhoto.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const cb = vi.fn();
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb);
|
||||
await flush();
|
||||
|
||||
expect(cb).toHaveBeenCalledWith({ photoUrl: null, thumbDataUrl: null });
|
||||
expect(svc.getCached('k')).toEqual({ photoUrl: null, thumbDataUrl: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// onPhotoLoaded
|
||||
// ==============================================================================
|
||||
|
||||
describe('onPhotoLoaded', () => {
|
||||
it('FE-COMP-PHOTO-010: listener fires once when photo is fetched', async () => {
|
||||
mockPlacePhoto.mockResolvedValue({ photoUrl: 'https://example.com/photo.jpg' });
|
||||
|
||||
const fn = vi.fn();
|
||||
svc.onPhotoLoaded('k', fn);
|
||||
svc.fetchPhoto('k', 'pid');
|
||||
await flush();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ photoUrl: 'https://example.com/photo.jpg' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTO-011: unsubscribe prevents callback from being called', async () => {
|
||||
mockPlacePhoto.mockResolvedValue({ photoUrl: 'https://example.com/photo.jpg' });
|
||||
|
||||
const fn = vi.fn();
|
||||
const unsub = svc.onPhotoLoaded('k', fn);
|
||||
unsub();
|
||||
svc.fetchPhoto('k', 'pid');
|
||||
await flush();
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// onThumbReady
|
||||
// ==============================================================================
|
||||
|
||||
describe('onThumbReady', () => {
|
||||
it('FE-COMP-PHOTO-012: fires when urlToBase64 produces a thumb', async () => {
|
||||
mockPlacePhoto.mockResolvedValue({ photoUrl: 'https://example.com/img.jpg' });
|
||||
setupImageAutoLoad(true); // trigger img.onload → canvas path runs
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue('data:image/webp;base64,thumb');
|
||||
|
||||
const fn = vi.fn();
|
||||
svc.onThumbReady('k', fn);
|
||||
svc.fetchPhoto('k', 'pid');
|
||||
|
||||
// flush microtasks + macrotasks to let urlToBase64 complete
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(fn).toHaveBeenCalledWith('data:image/webp;base64,thumb');
|
||||
expect(svc.getCached('k')?.thumbDataUrl).toBe('data:image/webp;base64,thumb');
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTO-013: unsubscribe prevents thumb callback', async () => {
|
||||
mockPlacePhoto.mockResolvedValue({ photoUrl: 'https://example.com/img.jpg' });
|
||||
setupImageAutoLoad(true);
|
||||
|
||||
const fn = vi.fn();
|
||||
const unsub = svc.onThumbReady('k', fn);
|
||||
unsub();
|
||||
svc.fetchPhoto('k', 'pid');
|
||||
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// urlToBase64
|
||||
// ==============================================================================
|
||||
|
||||
describe('urlToBase64', () => {
|
||||
it('FE-COMP-PHOTO-014: returns null when image fails to load', async () => {
|
||||
setupImageAutoLoad(false); // triggers onerror
|
||||
const result = await svc.urlToBase64('https://bad-url.jpg');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTO-015: returns a data URL string on successful load', async () => {
|
||||
setupImageAutoLoad(true);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue('data:image/webp;base64,abc123');
|
||||
|
||||
const result = await svc.urlToBase64('https://example.com/img.jpg', 48);
|
||||
expect(result).toBe('data:image/webp;base64,abc123');
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTO-016: canvas clip/draw path does not throw', async () => {
|
||||
setupImageAutoLoad(true);
|
||||
await expect(svc.urlToBase64('https://example.com/img.jpg')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ==============================================================================
|
||||
// getAllThumbs
|
||||
// ==============================================================================
|
||||
|
||||
describe('getAllThumbs', () => {
|
||||
it('FE-COMP-PHOTO-017: returns only entries with a non-null thumbDataUrl', async () => {
|
||||
// key1: photo with thumb
|
||||
mockPlacePhoto.mockResolvedValueOnce({ photoUrl: 'https://example.com/img1.jpg' });
|
||||
// key2: no photo, no thumb
|
||||
mockPlacePhoto.mockResolvedValueOnce({});
|
||||
|
||||
setupImageAutoLoad(true);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue('data:image/webp;base64,thumb1');
|
||||
|
||||
svc.fetchPhoto('key1', 'pid1');
|
||||
svc.fetchPhoto('key2', 'pid2');
|
||||
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const thumbs = svc.getAllThumbs();
|
||||
expect(Object.keys(thumbs)).toContain('key1');
|
||||
expect(thumbs['key1']).toBe('data:image/webp;base64,thumb1');
|
||||
expect(Object.keys(thumbs)).not.toContain('key2');
|
||||
});
|
||||
});
|
||||
@@ -79,4 +79,110 @@ describe('settingsStore', () => {
|
||||
expect(state.isLoaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-SETTINGS-006: setLanguageLocal updates state and localStorage', () => {
|
||||
it('sets language in state and localStorage without an API call', () => {
|
||||
useSettingsStore.getState().setLanguageLocal('ja');
|
||||
|
||||
const state = useSettingsStore.getState();
|
||||
expect(state.settings.language).toBe('ja');
|
||||
expect(localStorage.getItem('app_language')).toBe('ja');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-SETTINGS-007: setLanguageLocal without prior localStorage value', () => {
|
||||
it('writes to localStorage even when no prior value exists', () => {
|
||||
localStorage.clear();
|
||||
|
||||
useSettingsStore.getState().setLanguageLocal('ko');
|
||||
|
||||
const state = useSettingsStore.getState();
|
||||
expect(state.settings.language).toBe('ko');
|
||||
expect(localStorage.getItem('app_language')).toBe('ko');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-SETTINGS-008: updateSettings bulk update', () => {
|
||||
it('updates multiple settings keys and calls bulk API', async () => {
|
||||
await useSettingsStore.getState().updateSettings({ dark_mode: true, default_currency: 'JPY' });
|
||||
|
||||
const state = useSettingsStore.getState();
|
||||
expect(state.settings.dark_mode).toBe(true);
|
||||
expect(state.settings.default_currency).toBe('JPY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-SETTINGS-009: updateSettings optimistic update', () => {
|
||||
it('updates state synchronously before API resolves', async () => {
|
||||
const promise = useSettingsStore.getState().updateSettings({ dark_mode: true });
|
||||
|
||||
expect(useSettingsStore.getState().settings.dark_mode).toBe(true);
|
||||
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-SETTINGS-010: updateSettings API failure throws', () => {
|
||||
it('throws when bulk API returns 500', async () => {
|
||||
server.use(
|
||||
http.post('/api/settings/bulk', () =>
|
||||
HttpResponse.json({ error: 'Server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
useSettingsStore.getState().updateSettings({ dark_mode: true })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-SETTINGS-011: updateSetting non-language key does not write to localStorage', () => {
|
||||
it('does not modify app_language in localStorage', async () => {
|
||||
const before = localStorage.getItem('app_language');
|
||||
|
||||
await useSettingsStore.getState().updateSetting('dark_mode', true);
|
||||
|
||||
expect(localStorage.getItem('app_language')).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-SETTINGS-012: loadSettings merges server values with defaults', () => {
|
||||
it('preserves default keys not returned by server', async () => {
|
||||
server.use(
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({ settings: { dark_mode: true } })
|
||||
)
|
||||
);
|
||||
|
||||
await useSettingsStore.getState().loadSettings();
|
||||
|
||||
const state = useSettingsStore.getState();
|
||||
expect(state.settings.dark_mode).toBe(true);
|
||||
expect(state.settings.default_currency).toBe('USD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-SETTINGS-013: updateSetting for time_format', () => {
|
||||
it('updates time_format in state', async () => {
|
||||
await useSettingsStore.getState().updateSetting('time_format', '24h');
|
||||
|
||||
expect(useSettingsStore.getState().settings.time_format).toBe('24h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-SETTINGS-014: updateSetting API failure leaves optimistic state', () => {
|
||||
it('throws on API failure but keeps the optimistic state', async () => {
|
||||
server.use(
|
||||
http.put('/api/settings', () =>
|
||||
HttpResponse.json({ error: 'Server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
useSettingsStore.getState().updateSetting('default_zoom', 15)
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useSettingsStore.getState().settings.default_zoom).toBe(15);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,4 +145,260 @@ describe('vacayStore', () => {
|
||||
expect(state.selectedYear).toBe(2025);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-005: setSelectedYear and setSelectedUserId', () => {
|
||||
it('updates selectedYear state', () => {
|
||||
useVacayStore.getState().setSelectedYear(2028);
|
||||
expect(useVacayStore.getState().selectedYear).toBe(2028);
|
||||
});
|
||||
|
||||
it('updates selectedUserId state', () => {
|
||||
useVacayStore.getState().setSelectedUserId(42);
|
||||
expect(useVacayStore.getState().selectedUserId).toBe(42);
|
||||
});
|
||||
|
||||
it('sets selectedUserId to null', () => {
|
||||
useVacayStore.setState({ selectedUserId: 42 });
|
||||
useVacayStore.getState().setSelectedUserId(null);
|
||||
expect(useVacayStore.getState().selectedUserId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-006: loadEntries() uses selectedYear when no year arg', () => {
|
||||
it('falls back to selectedYear when called without argument', async () => {
|
||||
useVacayStore.setState({ selectedYear: 2025 });
|
||||
await useVacayStore.getState().loadEntries();
|
||||
expect(useVacayStore.getState().entries.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-007: loadStats() uses selectedYear when no year arg', () => {
|
||||
it('falls back to selectedYear when called without argument', async () => {
|
||||
useVacayStore.setState({ selectedYear: 2025 });
|
||||
await useVacayStore.getState().loadStats();
|
||||
expect(useVacayStore.getState().stats.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-008: invite()', () => {
|
||||
it('calls invite API and reloads plan', async () => {
|
||||
let inviteCalled = false;
|
||||
server.use(
|
||||
http.post('/api/addons/vacay/invite', () => {
|
||||
inviteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
await useVacayStore.getState().invite(5);
|
||||
const state = useVacayStore.getState();
|
||||
|
||||
expect(inviteCalled).toBe(true);
|
||||
expect(state.plan).not.toBeNull();
|
||||
expect(state.plan?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-009: declineInvite()', () => {
|
||||
it('calls decline API and reloads plan', async () => {
|
||||
await useVacayStore.getState().declineInvite(2);
|
||||
expect(useVacayStore.getState().plan?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-010: cancelInvite()', () => {
|
||||
it('calls cancel API and reloads plan', async () => {
|
||||
await useVacayStore.getState().cancelInvite(3);
|
||||
const state = useVacayStore.getState();
|
||||
expect(state.plan).not.toBeNull();
|
||||
expect(state.plan?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-011: acceptInvite()', () => {
|
||||
it('calls loadAll after accepting invite', async () => {
|
||||
await useVacayStore.getState().acceptInvite(1);
|
||||
const state = useVacayStore.getState();
|
||||
|
||||
expect(state.plan).not.toBeNull();
|
||||
expect(state.years).toEqual([2025, 2026]);
|
||||
expect(state.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-012: dissolve()', () => {
|
||||
it('calls loadAll after dissolving', async () => {
|
||||
await useVacayStore.getState().dissolve();
|
||||
const state = useVacayStore.getState();
|
||||
|
||||
expect(state.plan).not.toBeNull();
|
||||
expect(state.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-013: updateColor()', () => {
|
||||
it('reloads plan and entries after updating color', async () => {
|
||||
server.use(
|
||||
http.put('/api/addons/vacay/color', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
|
||||
await useVacayStore.getState().updateColor('#ff0000');
|
||||
const state = useVacayStore.getState();
|
||||
|
||||
expect(state.plan?.id).toBe(1);
|
||||
expect(state.entries.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-014: toggleCompanyHoliday()', () => {
|
||||
it('reloads entries and stats after toggling company holiday', async () => {
|
||||
useVacayStore.setState({ selectedYear: 2025 });
|
||||
|
||||
server.use(
|
||||
http.post('/api/addons/vacay/entries/company-holiday', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
|
||||
await useVacayStore.getState().toggleCompanyHoliday('2025-12-26');
|
||||
const state = useVacayStore.getState();
|
||||
|
||||
expect(state.entries.length).toBe(2);
|
||||
expect(state.stats.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-015: updateVacationDays()', () => {
|
||||
it('reloads stats for the given year', async () => {
|
||||
await useVacayStore.getState().updateVacationDays(2025, 25);
|
||||
expect(useVacayStore.getState().stats.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-016: removeYear() when selectedYear is not the removed year', () => {
|
||||
it('does not change selectedYear when a different year is removed', async () => {
|
||||
useVacayStore.setState({ years: [2025, 2026], selectedYear: 2025 });
|
||||
|
||||
await useVacayStore.getState().removeYear(2026);
|
||||
const state = useVacayStore.getState();
|
||||
|
||||
expect(state.years).toEqual([2025]);
|
||||
expect(state.selectedYear).toBe(2025);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-017: addHolidayCalendar()', () => {
|
||||
it('reloads plan and holidays after adding a holiday calendar', async () => {
|
||||
server.use(
|
||||
http.post('/api/addons/vacay/plan/holiday-calendars', () =>
|
||||
HttpResponse.json({
|
||||
calendar: { id: 1, plan_id: 1, region: 'DE', label: null, color: '#ef4444', sort_order: 0 },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await useVacayStore.getState().addHolidayCalendar({ region: 'DE', color: '#ef4444' });
|
||||
expect(useVacayStore.getState().plan?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-018: updateHolidayCalendar()', () => {
|
||||
it('reloads plan and holidays after updating a holiday calendar', async () => {
|
||||
server.use(
|
||||
http.put('/api/addons/vacay/plan/holiday-calendars/:id', () =>
|
||||
HttpResponse.json({
|
||||
calendar: { id: 1, plan_id: 1, region: 'US', label: 'US Holidays', color: '#3b82f6', sort_order: 0 },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await useVacayStore.getState().updateHolidayCalendar(1, { label: 'US Holidays' });
|
||||
expect(useVacayStore.getState().plan?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-019: deleteHolidayCalendar()', () => {
|
||||
it('reloads plan and holidays after deleting a holiday calendar', async () => {
|
||||
await useVacayStore.getState().deleteHolidayCalendar(1);
|
||||
expect(useVacayStore.getState().plan?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-020: loadHolidays() with regional calendar includes matching counties', () => {
|
||||
it('includes holidays matching the region county and excludes non-matching ones', async () => {
|
||||
useVacayStore.setState({
|
||||
selectedYear: 2025,
|
||||
plan: {
|
||||
id: 1,
|
||||
holidays_enabled: true,
|
||||
holidays_region: null,
|
||||
holiday_calendars: [
|
||||
{ id: 1, plan_id: 1, region: 'DE-BY', label: null, color: '#ef4444', sort_order: 0 },
|
||||
],
|
||||
block_weekends: false,
|
||||
carry_over_enabled: false,
|
||||
company_holidays_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.get('/api/addons/vacay/holidays/:year/:country', () =>
|
||||
HttpResponse.json([
|
||||
{ date: '2025-11-01', name: 'All Saints Day', localName: 'Allerheiligen', global: false, counties: ['DE-BY', 'DE-BW'] },
|
||||
{ date: '2025-08-15', name: 'Assumption Day', localName: 'Mariä Himmelfahrt', global: false, counties: ['DE-BY'] },
|
||||
{ date: '2025-03-19', name: 'St. Joseph', localName: 'Sankt Joseph', global: false, counties: ['DE-NW'] },
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
await useVacayStore.getState().loadHolidays(2025);
|
||||
const holidays = useVacayStore.getState().holidays;
|
||||
|
||||
// DE-BY holidays should be included
|
||||
expect(holidays['2025-11-01']).toBeDefined();
|
||||
expect(holidays['2025-08-15']).toBeDefined();
|
||||
// DE-NW only holiday should be excluded
|
||||
expect(holidays['2025-03-19']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-VACAY-021: loadHolidays() skips regional calendar when data has no county breakdown', () => {
|
||||
it('results in empty holidays map when all entries are global (no counties)', async () => {
|
||||
useVacayStore.setState({
|
||||
selectedYear: 2025,
|
||||
plan: {
|
||||
id: 1,
|
||||
holidays_enabled: true,
|
||||
holidays_region: null,
|
||||
holiday_calendars: [
|
||||
{ id: 1, plan_id: 1, region: 'DE-BY', label: null, color: '#ef4444', sort_order: 0 },
|
||||
],
|
||||
block_weekends: false,
|
||||
carry_over_enabled: false,
|
||||
company_holidays_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
// hasRegions is false (no counties), region is 'DE-BY' (non-null)
|
||||
// so the condition `hasRegions && !region` is false → proceeds to county filter
|
||||
// h.global is true → all holidays are included despite region filter
|
||||
// Actually: global=true entries are included by the `h.global` check in the forEach
|
||||
// The test verifies behavior when counties: null + global: true
|
||||
const holidays = useVacayStore.getState().holidays;
|
||||
// Global holidays are included even for regional calendars when counties data is absent
|
||||
expect(holidays['2025-12-25']).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user