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
+224
View File
@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../helpers/msw/server';
import { buildUser } from '../../helpers/factories';
// The global setup.ts mocks websocket with getSocketId returning null.
// We need to be able to control what getSocketId returns per-test.
// Re-mock here to get full control.
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => 'mock-socket-id'),
setRefetchCallback: vi.fn(),
joinTrip: vi.fn(),
leaveTrip: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
const wsMock = await import('../../../src/api/websocket');
// Import the API client AFTER the mock is set up so it picks up our getSocketId mock
const { authApi } = await import('../../../src/api/client');
describe('API client interceptors', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: socket ID available
(wsMock.getSocketId as ReturnType<typeof vi.fn>).mockReturnValue('mock-socket-id');
});
afterEach(() => {
// Reset window.location to a neutral path
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/', pathname: '/', search: '', hash: '' },
});
});
it('FE-API-001: requests include X-Socket-Id header when getSocketId returns a value', async () => {
let receivedSocketId: string | null = null;
server.use(
http.get('/api/auth/me', ({ request }) => {
receivedSocketId = request.headers.get('X-Socket-Id');
return HttpResponse.json({ user: buildUser() });
})
);
await authApi.me();
expect(receivedSocketId).toBe('mock-socket-id');
});
it('FE-API-002: X-Socket-Id header is absent when getSocketId returns null', async () => {
(wsMock.getSocketId as ReturnType<typeof vi.fn>).mockReturnValue(null);
let receivedSocketId: string | null = 'sentinel';
server.use(
http.get('/api/auth/me', ({ request }) => {
receivedSocketId = request.headers.get('X-Socket-Id');
return HttpResponse.json({ user: buildUser() });
})
);
await authApi.me();
expect(receivedSocketId).toBeNull();
});
it('FE-API-003: 401 with AUTH_REQUIRED → redirects to /login with redirect param', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/', pathname: '/dashboard', search: '', hash: '' },
});
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 });
})
);
try {
await authApi.me();
} catch {
// Expected to reject
}
expect(window.location.href).toBe('/login?redirect=%2Fdashboard');
});
it('FE-API-003b: 401 without AUTH_REQUIRED code does not redirect', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '' },
});
const originalHref = window.location.href;
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
})
);
try {
await authApi.me();
} catch {
// Expected to reject
}
expect(window.location.href).toBe(originalHref);
});
it('FE-API-003c: 401 on /login page does not redirect', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/login', pathname: '/login', search: '' },
});
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 });
})
);
try {
await authApi.me();
} catch {
// Expected to reject
}
// href should NOT have been changed to /login?redirect=...
expect(window.location.href).toBe('http://localhost/login');
});
it('FE-API-004: 403 with MFA_REQUIRED → redirects to /settings?mfa=required', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/', pathname: '/dashboard', search: '' },
});
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 });
})
);
try {
await authApi.me();
} catch {
// Expected to reject
}
expect(window.location.href).toBe('/settings?mfa=required');
});
it('FE-API-004b: 403 with MFA_REQUIRED on /settings page does not redirect', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/settings', pathname: '/settings', search: '' },
});
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 });
})
);
try {
await authApi.me();
} catch {
// Expected to reject
}
// Should NOT redirect when already on /settings
expect(window.location.href).toBe('http://localhost/settings');
});
it('FE-API-005: successful API call returns response data', async () => {
const user = buildUser();
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ user });
})
);
const data = await authApi.me();
expect(data).toMatchObject({ user: { id: user.id, email: user.email } });
});
it('FE-API-006: socket ID header reflects current value from getSocketId at request time', async () => {
const headers: Array<string | null> = [];
(wsMock.getSocketId as ReturnType<typeof vi.fn>)
.mockReturnValueOnce('socket-A')
.mockReturnValueOnce('socket-B');
server.use(
http.get('/api/auth/me', ({ request }) => {
headers.push(request.headers.get('X-Socket-Id'));
return HttpResponse.json({ user: buildUser() });
})
);
await authApi.me();
await authApi.me();
expect(headers[0]).toBe('socket-A');
expect(headers[1]).toBe('socket-B');
});
it('FE-API-007: non-401/403 errors are passed through as rejections', async () => {
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ error: 'Internal error' }, { status: 500 });
})
);
await expect(authApi.me()).rejects.toThrow();
});
});
@@ -0,0 +1,447 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { useDayNotes } from '../../../src/hooks/useDayNotes';
import { useTripStore } from '../../../src/store/tripStore';
import { TranslationProvider } from '../../../src/i18n/TranslationContext';
import { server } from '../../helpers/msw/server';
import { buildDayNote } from '../../helpers/factories';
import { resetAllStores } from '../../helpers/store';
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(TranslationProvider, null, children);
const TRIP_ID = 1;
const DAY_ID = 10;
describe('useDayNotes', () => {
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
});
it('FE-HOOK-DAYNOTES-001: initial noteUi state is empty', () => {
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
expect(result.current.noteUi).toEqual({});
});
it('FE-HOOK-DAYNOTES-002: initial dayNotes comes from tripStore', () => {
const note = buildDayNote({ day_id: DAY_ID });
useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
expect(result.current.dayNotes[String(DAY_ID)]).toEqual([note]);
});
it('FE-HOOK-DAYNOTES-003: openAddNote sets mode=add and default sort order', () => {
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.openAddNote(DAY_ID, () => []);
});
expect(result.current.noteUi[DAY_ID]).toMatchObject({
mode: 'add',
text: '',
sortOrder: 0, // maxKey(-1) + 1 = 0
});
});
it('FE-HOOK-DAYNOTES-004: openAddNote calculates sortOrder as max(sortKey) + 1 from merged items', () => {
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 5, data: buildDayNote() },
{ type: 'note' as const, sortKey: 10, data: buildDayNote() },
];
act(() => {
result.current.openAddNote(DAY_ID, getMergedItems);
});
expect(result.current.noteUi[DAY_ID]).toMatchObject({
mode: 'add',
sortOrder: 11, // max(5,10) + 1
});
});
it('FE-HOOK-DAYNOTES-005: openEditNote sets mode=edit with note data', () => {
const note = buildDayNote({ id: 99, text: 'Hello', time: '10:00', icon: 'Star' });
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.openEditNote(DAY_ID, note);
});
expect(result.current.noteUi[DAY_ID]).toMatchObject({
mode: 'edit',
noteId: 99,
text: 'Hello',
time: '10:00',
icon: 'Star',
});
});
it('FE-HOOK-DAYNOTES-006: cancelNote removes the UI entry for that day', () => {
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.openAddNote(DAY_ID, () => []);
});
expect(result.current.noteUi[DAY_ID]).toBeDefined();
act(() => {
result.current.cancelNote(DAY_ID);
});
expect(result.current.noteUi[DAY_ID]).toBeUndefined();
});
it('FE-HOOK-DAYNOTES-007: saveNote with empty text is a no-op', async () => {
const spy = vi.fn();
server.use(
http.post('/api/trips/:id/days/:dayId/notes', () => {
spy();
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.setNoteUi({ [DAY_ID]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: 0 } });
});
await act(async () => {
await result.current.saveNote(DAY_ID);
});
expect(spy).not.toHaveBeenCalled();
// noteUi remains set (no cancelNote was called)
expect(result.current.noteUi[DAY_ID]).toBeDefined();
});
it('FE-HOOK-DAYNOTES-008: saveNote in add mode calls addDayNote and clears UI', async () => {
const createdNote = buildDayNote({ day_id: DAY_ID, text: 'New note' });
server.use(
http.post('/api/trips/:id/days/:dayId/notes', async () => {
return HttpResponse.json({ note: createdNote });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.setNoteUi({
[DAY_ID]: { mode: 'add', text: 'New note', time: '', icon: 'FileText', sortOrder: 0 },
});
});
await act(async () => {
await result.current.saveNote(DAY_ID);
});
// UI should be cleared after successful save
expect(result.current.noteUi[DAY_ID]).toBeUndefined();
});
it('FE-HOOK-DAYNOTES-009: saveNote in edit mode calls updateDayNote and clears UI', async () => {
const noteId = 55;
const updatedNote = buildDayNote({ id: noteId, day_id: DAY_ID, text: 'Updated' });
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async () => {
return HttpResponse.json({ note: updatedNote });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.setNoteUi({
[DAY_ID]: { mode: 'edit', noteId, text: 'Updated', time: '', icon: 'FileText' },
});
});
await act(async () => {
await result.current.saveNote(DAY_ID);
});
expect(result.current.noteUi[DAY_ID]).toBeUndefined();
});
it('FE-HOOK-DAYNOTES-010: deleteNote calls deleteDayNote on the store', async () => {
const note = buildDayNote({ id: 77, day_id: DAY_ID });
useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
server.use(
http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
return HttpResponse.json({ success: true });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
await act(async () => {
await result.current.deleteNote(DAY_ID, 77);
});
// Note should be removed from the store
const dayNotes = useTripStore.getState().dayNotes[String(DAY_ID)] || [];
expect(dayNotes.find((n) => n.id === 77)).toBeUndefined();
});
it('FE-HOOK-DAYNOTES-011: saveNote on API error shows toast', async () => {
const toastSpy = vi.fn();
window.__addToast = toastSpy;
server.use(
http.post('/api/trips/:id/days/:dayId/notes', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.setNoteUi({
[DAY_ID]: { mode: 'add', text: 'Test note', time: '', icon: 'FileText', sortOrder: 0 },
});
});
await act(async () => {
await result.current.saveNote(DAY_ID);
});
expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
delete window.__addToast;
});
it('FE-HOOK-DAYNOTES-012: deleteNote on API error shows toast', async () => {
const toastSpy = vi.fn();
window.__addToast = toastSpy;
const note = buildDayNote({ id: 88, day_id: DAY_ID });
useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
server.use(
http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
await act(async () => {
await result.current.deleteNote(DAY_ID, 88);
});
expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
delete window.__addToast;
});
it('FE-HOOK-DAYNOTES-013: moveNote up calculates midpoint sort order', async () => {
let capturedBody: Record<string, unknown> = {};
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const noteB = buildDayNote({ id: 2 });
const noteC = buildDayNote({ id: 3 });
// merged items with sortKeys 0, 2, 4
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 0, data: noteA },
{ type: 'note' as const, sortKey: 2, data: noteB },
{ type: 'note' as const, sortKey: 4, data: noteC },
];
// Move noteC (idx=2) up → new order should be between idx=0 and idx=1 → (0+2)/2 = 1
await act(async () => {
await result.current.moveNote(DAY_ID, noteC.id, 'up', getMergedItems);
});
expect(capturedBody.sort_order).toBe(1); // (sortKey[0] + sortKey[1]) / 2 = (0+2)/2
});
it('FE-HOOK-DAYNOTES-014: moveNote down calculates midpoint sort order', async () => {
let capturedBody: Record<string, unknown> = {};
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const noteB = buildDayNote({ id: 2 });
const noteC = buildDayNote({ id: 3 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 0, data: noteA },
{ type: 'note' as const, sortKey: 2, data: noteB },
{ type: 'note' as const, sortKey: 4, data: noteC },
];
// Move noteA (idx=0) down → new order between idx=1 and idx=2 → (2+4)/2 = 3
await act(async () => {
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
});
expect(capturedBody.sort_order).toBe(3); // (sortKey[1] + sortKey[2]) / 2 = (2+4)/2
});
it('FE-HOOK-DAYNOTES-015: moveNote up at index 0 is a no-op', async () => {
const spy = vi.fn();
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
spy();
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 0, data: noteA },
];
await act(async () => {
await result.current.moveNote(DAY_ID, noteA.id, 'up', getMergedItems);
});
expect(spy).not.toHaveBeenCalled();
});
it('FE-HOOK-DAYNOTES-016: moveNote down at last index is a no-op', async () => {
const spy = vi.fn();
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
spy();
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 0, data: noteA },
];
await act(async () => {
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
});
expect(spy).not.toHaveBeenCalled();
});
it('FE-HOOK-DAYNOTES-017: moveNote down at last item uses sortKey + 1', async () => {
let capturedBody: Record<string, unknown> = {};
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const noteB = buildDayNote({ id: 2 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 5, data: noteA },
{ type: 'note' as const, sortKey: 10, data: noteB },
];
// Move noteA (idx=0) down — only 2 items, so idx < length-1 is false after going down
// direction=down, idx=0, length=2, idx < length-2 is false (0 < 0), so newSortOrder = sortKey[1]+1 = 11
await act(async () => {
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
});
expect(capturedBody.sort_order).toBe(11); // sortKey[idx+1] + 1 = 10 + 1
});
it('FE-HOOK-DAYNOTES-018: moveNote on error shows toast', async () => {
const toastSpy = vi.fn();
window.__addToast = toastSpy;
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const noteB = buildDayNote({ id: 2 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 0, data: noteA },
{ type: 'note' as const, sortKey: 1, data: noteB },
];
await act(async () => {
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
});
expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
delete window.__addToast;
});
it('FE-HOOK-DAYNOTES-019: moveNote up with only 1 item before uses sortKey - 1', async () => {
let capturedBody: Record<string, unknown> = {};
server.use(
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ note: buildDayNote() });
})
);
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
const noteA = buildDayNote({ id: 1 });
const noteB = buildDayNote({ id: 2 });
const getMergedItems = () => [
{ type: 'note' as const, sortKey: 5, data: noteA },
{ type: 'note' as const, sortKey: 10, data: noteB },
];
// Move noteB (idx=1) up — idx >= 2 is false, so newSortOrder = sortKey[idx-1] - 1 = 5-1 = 4
await act(async () => {
await result.current.moveNote(DAY_ID, noteB.id, 'up', getMergedItems);
});
expect(capturedBody.sort_order).toBe(4); // sortKey[0] - 1 = 5 - 1
});
it('FE-HOOK-DAYNOTES-020: openAddNote calls expandDay if provided', () => {
const expandDay = vi.fn();
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
act(() => {
result.current.openAddNote(DAY_ID, () => [], expandDay);
});
expect(expandDay).toHaveBeenCalledWith(DAY_ID);
});
});
// Type augment for window.__addToast
declare global {
interface Window {
__addToast?: (message: string, type: string, duration?: number) => void;
}
}
@@ -0,0 +1,225 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useInAppNotificationStore } from '../../../src/store/inAppNotificationStore';
import { resetAllStores } from '../../helpers/store';
// Capture the listener registered via addListener so we can simulate WS events
let capturedListener: ((event: Record<string, unknown>) => void) | null = null;
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
joinTrip: vi.fn(),
leaveTrip: vi.fn(),
addListener: vi.fn((fn) => {
capturedListener = fn;
}),
removeListener: vi.fn(),
}));
const wsMock = await import('../../../src/api/websocket');
// Import the hook after the mock is in place
const { useInAppNotificationListener } = await import('../../../src/hooks/useInAppNotificationListener');
describe('useInAppNotificationListener', () => {
beforeEach(() => {
capturedListener = null;
resetAllStores();
vi.clearAllMocks();
// Re-capture after clear
(wsMock.addListener as ReturnType<typeof vi.fn>).mockImplementation((fn) => {
capturedListener = fn;
});
});
it('FE-HOOK-NOTIFLISTENER-001: on mount, addListener is called once', () => {
const { unmount } = renderHook(() => useInAppNotificationListener());
expect(wsMock.addListener).toHaveBeenCalledTimes(1);
unmount();
});
it('FE-HOOK-NOTIFLISTENER-002: on unmount, removeListener is called with the same function', () => {
const { unmount } = renderHook(() => useInAppNotificationListener());
const registeredFn = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
unmount();
expect(wsMock.removeListener).toHaveBeenCalledWith(registeredFn);
});
it('FE-HOOK-NOTIFLISTENER-003: notification:new event calls handleNewNotification on the store', () => {
const handleNew = vi.fn();
useInAppNotificationStore.setState({ handleNewNotification: handleNew } as any);
const { unmount } = renderHook(() => useInAppNotificationListener());
expect(capturedListener).toBeTypeOf('function');
const notification = {
id: 1, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
sender_avatar: null, recipient_id: 2, title_key: 'test', title_params: '{}',
text_key: 'test_body', 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:00Z',
};
act(() => {
capturedListener!({ type: 'notification:new', notification });
});
expect(handleNew).toHaveBeenCalledWith(notification);
unmount();
});
it('FE-HOOK-NOTIFLISTENER-004: notification:updated event calls handleUpdatedNotification on the store', () => {
const handleUpdated = vi.fn();
useInAppNotificationStore.setState({ handleUpdatedNotification: handleUpdated } as any);
const { unmount } = renderHook(() => useInAppNotificationListener());
const notification = {
id: 5, type: 'simple', scope: 'user', target: 1, sender_id: null, sender_username: null,
sender_avatar: null, recipient_id: 2, title_key: 'updated', title_params: '{}',
text_key: 'updated_body', text_params: '{}', positive_text_key: null, negative_text_key: null,
response: 'positive', navigate_text_key: null, navigate_target: null, is_read: 1,
created_at: '2025-01-01T00:00:00Z',
};
act(() => {
capturedListener!({ type: 'notification:updated', notification });
});
expect(handleUpdated).toHaveBeenCalledWith(notification);
unmount();
});
it('FE-HOOK-NOTIFLISTENER-005: unrelated event types are ignored', () => {
const handleNew = vi.fn();
const handleUpdated = vi.fn();
useInAppNotificationStore.setState({
handleNewNotification: handleNew,
handleUpdatedNotification: handleUpdated,
} as any);
const { unmount } = renderHook(() => useInAppNotificationListener());
act(() => {
capturedListener!({ type: 'place:created', data: {} });
});
expect(handleNew).not.toHaveBeenCalled();
expect(handleUpdated).not.toHaveBeenCalled();
unmount();
});
it('FE-HOOK-NOTIFLISTENER-006: notification:new actually updates the store unreadCount', () => {
renderHook(() => useInAppNotificationListener());
const initialCount = useInAppNotificationStore.getState().unreadCount;
act(() => {
capturedListener!({
type: 'notification:new',
notification: {
id: 99, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
sender_avatar: null, recipient_id: 2, title_key: 'test', title_params: {},
text_key: 'body', text_params: {}, positive_text_key: null, negative_text_key: null,
response: null, navigate_text_key: null, navigate_target: null, is_read: false,
created_at: '2025-01-01T00:00:00Z',
},
});
});
expect(useInAppNotificationStore.getState().unreadCount).toBe(initialCount + 1);
});
it('FE-HOOK-NOTIFLISTENER-007: notification:updated updates the notification in the store', () => {
// Seed a notification
useInAppNotificationStore.setState({
notifications: [{
id: 10, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
sender_avatar: null, recipient_id: 2, title_key: 'test', title_params: {},
text_key: 'body', text_params: {}, positive_text_key: null, negative_text_key: null,
response: null, navigate_text_key: null, navigate_target: null, is_read: false,
created_at: '2025-01-01T00:00:00Z',
}],
});
renderHook(() => useInAppNotificationListener());
act(() => {
capturedListener!({
type: 'notification:updated',
notification: {
id: 10, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
sender_avatar: null, recipient_id: 2, title_key: 'test', title_params: {},
text_key: 'body', text_params: {}, positive_text_key: null, negative_text_key: null,
response: 'positive', navigate_text_key: null, navigate_target: null, is_read: true,
created_at: '2025-01-01T00:00:00Z',
},
});
});
const updated = useInAppNotificationStore.getState().notifications.find((n) => n.id === 10);
expect(updated?.response).toBe('positive');
expect(updated?.is_read).toBe(true);
});
it('FE-HOOK-NOTIFLISTENER-008: multiple events processed correctly in sequence', () => {
const { unmount } = renderHook(() => useInAppNotificationListener());
const initial = useInAppNotificationStore.getState().unreadCount;
act(() => {
capturedListener!({
type: 'notification:new',
notification: {
id: 101, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
sender_avatar: null, recipient_id: 2, title_key: 'k1', title_params: {},
text_key: 'b1', text_params: {}, positive_text_key: null, negative_text_key: null,
response: null, navigate_text_key: null, navigate_target: null, is_read: false,
created_at: '2025-01-01T00:00:00Z',
},
});
capturedListener!({
type: 'notification:new',
notification: {
id: 102, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
sender_avatar: null, recipient_id: 2, title_key: 'k2', title_params: {},
text_key: 'b2', text_params: {}, positive_text_key: null, negative_text_key: null,
response: null, navigate_text_key: null, navigate_target: null, is_read: false,
created_at: '2025-01-01T00:00:00Z',
},
});
});
expect(useInAppNotificationStore.getState().unreadCount).toBe(initial + 2);
unmount();
});
it('FE-HOOK-NOTIFLISTENER-009: listener added on mount is the same one removed on unmount', () => {
const { unmount } = renderHook(() => useInAppNotificationListener());
const addedFn = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
unmount();
const removedFn = (wsMock.removeListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(addedFn).toBe(removedFn);
});
it('FE-HOOK-NOTIFLISTENER-010: after unmount, listener no longer processes events', () => {
const handleNew = vi.fn();
useInAppNotificationStore.setState({ handleNewNotification: handleNew } as any);
const { unmount } = renderHook(() => useInAppNotificationListener());
unmount();
// capturedListener is captured but the component is unmounted
// The removeListener was called — the actual implementation would have unregistered it
// We verify removeListener was called (the cleanup ran)
expect(wsMock.removeListener).toHaveBeenCalled();
});
});
@@ -0,0 +1,168 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent } from '@testing-library/react';
import { useResizablePanels } from '../../../src/hooks/useResizablePanels';
describe('useResizablePanels', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
it('FE-HOOK-PANELS-001: default leftWidth is 340 when localStorage is empty', () => {
const { result } = renderHook(() => useResizablePanels());
expect(result.current.leftWidth).toBe(340);
});
it('FE-HOOK-PANELS-002: default rightWidth is 300 when localStorage is empty', () => {
const { result } = renderHook(() => useResizablePanels());
expect(result.current.rightWidth).toBe(300);
});
it('FE-HOOK-PANELS-003: leftWidth loaded from localStorage when set', () => {
localStorage.setItem('sidebarLeftWidth', '400');
const { result } = renderHook(() => useResizablePanels());
expect(result.current.leftWidth).toBe(400);
});
it('FE-HOOK-PANELS-004: rightWidth loaded from localStorage when set', () => {
localStorage.setItem('sidebarRightWidth', '350');
const { result } = renderHook(() => useResizablePanels());
expect(result.current.rightWidth).toBe(350);
});
it('FE-HOOK-PANELS-005: startResizeLeft sets body cursor to col-resize', () => {
const { result } = renderHook(() => useResizablePanels());
act(() => {
result.current.startResizeLeft();
});
expect(document.body.style.cursor).toBe('col-resize');
});
it('FE-HOOK-PANELS-006: startResizeRight sets body cursor to col-resize', () => {
const { result } = renderHook(() => useResizablePanels());
act(() => {
result.current.startResizeRight();
});
expect(document.body.style.cursor).toBe('col-resize');
});
it('FE-HOOK-PANELS-007: mousedown → mousemove → mouseup updates leftWidth and persists to localStorage', async () => {
const { result } = renderHook(() => useResizablePanels());
act(() => {
result.current.startResizeLeft();
});
// mousemove with clientX=350 → w = max(200, min(520, 350-10)) = 340
act(() => {
fireEvent.mouseMove(document, { clientX: 350 });
});
expect(result.current.leftWidth).toBe(340);
expect(localStorage.getItem('sidebarLeftWidth')).toBe('340');
act(() => {
fireEvent.mouseUp(document);
});
expect(document.body.style.cursor).toBe('');
});
it('FE-HOOK-PANELS-008: mousedown → mousemove → mouseup updates rightWidth and persists to localStorage', () => {
// Set window.innerWidth for the right panel calculation
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 });
const { result } = renderHook(() => useResizablePanels());
act(() => {
result.current.startResizeRight();
});
// mousemove with clientX=800 → w = max(200, min(520, 1200-800-10)) = max(200, min(520, 390)) = 390
act(() => {
fireEvent.mouseMove(document, { clientX: 800 });
});
expect(result.current.rightWidth).toBe(390);
expect(localStorage.getItem('sidebarRightWidth')).toBe('390');
act(() => {
fireEvent.mouseUp(document);
});
expect(document.body.style.cursor).toBe('');
});
it('FE-HOOK-PANELS-009: min width constraint (200) is enforced for left panel', () => {
const { result } = renderHook(() => useResizablePanels());
act(() => {
result.current.startResizeLeft();
});
// clientX=50 → w = max(200, min(520, 50-10)) = max(200, 40) = 200
act(() => {
fireEvent.mouseMove(document, { clientX: 50 });
});
expect(result.current.leftWidth).toBe(200);
});
it('FE-HOOK-PANELS-010: max width constraint (520) is enforced for left panel', () => {
const { result } = renderHook(() => useResizablePanels());
act(() => {
result.current.startResizeLeft();
});
// clientX=600 → w = max(200, min(520, 600-10)) = min(520, 590) = 520
act(() => {
fireEvent.mouseMove(document, { clientX: 600 });
});
expect(result.current.leftWidth).toBe(520);
});
it('FE-HOOK-PANELS-011: mousemove without prior startResize does nothing', () => {
const { result } = renderHook(() => useResizablePanels());
const initialLeft = result.current.leftWidth;
const initialRight = result.current.rightWidth;
act(() => {
fireEvent.mouseMove(document, { clientX: 400 });
});
expect(result.current.leftWidth).toBe(initialLeft);
expect(result.current.rightWidth).toBe(initialRight);
});
it('FE-HOOK-PANELS-012: body userSelect set to none during resize, cleared on mouseup', () => {
const { result } = renderHook(() => useResizablePanels());
act(() => {
result.current.startResizeLeft();
});
expect(document.body.style.userSelect).toBe('none');
act(() => {
fireEvent.mouseUp(document);
});
expect(document.body.style.userSelect).toBe('');
});
it('FE-HOOK-PANELS-013: leftCollapsed and rightCollapsed default to false', () => {
const { result } = renderHook(() => useResizablePanels());
expect(result.current.leftCollapsed).toBe(false);
expect(result.current.rightCollapsed).toBe(false);
});
it('FE-HOOK-PANELS-014: setLeftCollapsed and setRightCollapsed are exposed', () => {
const { result } = renderHook(() => useResizablePanels());
expect(result.current.setLeftCollapsed).toBeTypeOf('function');
expect(result.current.setRightCollapsed).toBeTypeOf('function');
});
});
@@ -0,0 +1,307 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
import { useSettingsStore } from '../../../src/store/settingsStore';
import { buildAssignment, buildPlace } from '../../helpers/factories';
import type { TripStoreState } from '../../../src/store/tripStore';
import type { RouteSegment } from '../../../src/types';
// Mock the RouteCalculator module to avoid real OSRM fetch calls
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
calculateSegments: vi.fn(),
calculateRoute: vi.fn(),
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
generateGoogleMapsUrl: vi.fn(),
}));
const { calculateSegments } = await import('../../../src/components/Map/RouteCalculator');
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
return { assignments } as Partial<TripStoreState>;
}
const MOCK_SEGMENTS: RouteSegment[] = [
{
from: [48.8566, 2.3522],
to: [51.5074, -0.1278],
mid: [50.182, 1.1122],
walkingText: '120 min',
drivingText: '90 min',
},
];
describe('useRouteCalculation', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: route_calculation disabled
useSettingsStore.setState({ settings: { route_calculation: false } as any });
(calculateSegments as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_SEGMENTS);
});
it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
const store = buildMockStore({});
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, null)
);
expect(result.current.route).toBeNull();
});
it('FE-HOOK-ROUTE-002: with < 2 waypoints, route remains null', async () => {
const place = buildPlace({ lat: 48.8566, lng: 2.3522 });
const assignment = buildAssignment({ day_id: 5, order_index: 0, place });
const store = buildMockStore({ '5': [assignment] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
expect(result.current.route).toBeNull();
});
it('FE-HOOK-ROUTE-003: with ≥ 2 geo-coded assignments, sets route coordinates', async () => {
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
expect(result.current.route).toEqual([
[p1.lat, p1.lng],
[p2.lat, p2.lng],
]);
});
it('FE-HOOK-ROUTE-004: with route_calculation enabled, calls calculateSegments', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
expect(calculateSegments).toHaveBeenCalled();
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS);
});
it('FE-HOOK-ROUTE-005: with route_calculation disabled, does not call calculateSegments', async () => {
useSettingsStore.setState({ settings: { route_calculation: false } as any });
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
expect(calculateSegments).not.toHaveBeenCalled();
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-006: assignments are sorted by order_index before extracting waypoints', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
// order_index 1 comes before 0 in the array, but should be sorted
const a1 = buildAssignment({ day_id: 5, order_index: 1, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 0, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
// After sort: a2 (order_index=0) first, then a1 (order_index=1)
expect(result.current.route).toEqual([
[p2.lat, p2.lng],
[p1.lat, p1.lng],
]);
});
it('FE-HOOK-ROUTE-007: assignments with no lat/lng are filtered out', async () => {
const pValid = buildPlace({ lat: 48.8566, lng: 2.3522 });
const pNoGeo = buildPlace({ lat: null as any, lng: null as any });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: pNoGeo });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: pValid });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
// Only 1 valid waypoint → route is null
expect(result.current.route).toBeNull();
});
it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
// Make calculateSegments resolve slowly
let resolveSegments!: (val: RouteSegment[]) => void;
(calculateSegments as ReturnType<typeof vi.fn>).mockImplementationOnce(
(_waypoints: unknown[], options: { signal?: AbortSignal }) => {
return new Promise<RouteSegment[]>((resolve) => {
resolveSegments = resolve;
options?.signal?.addEventListener('abort', () => resolve([]));
});
}
);
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store1 = buildMockStore({ '5': [a1, a2], '6': [a1, a2] });
const { rerender } = renderHook(
({ dayId }: { dayId: number }) => useRouteCalculation(store1 as TripStoreState, dayId),
{ initialProps: { dayId: 5 } }
);
// Change to day 6 — should abort in-flight request for day 5
await act(async () => {
rerender({ dayId: 6 });
});
// calculateSegments should have been called at least once for day 5
// and once more for day 6
expect((calculateSegments as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
// Cleanup
resolveSegments?.([]);
});
it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
// AbortError should be swallowed silently — segments remain empty
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-011: when selectedDayId is null, route and segments are cleared', async () => {
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result, rerender } = renderHook(
({ dayId }: { dayId: number | null }) => useRouteCalculation(store as TripStoreState, dayId),
{ initialProps: { dayId: 5 as number | null } }
);
await act(async () => {});
// Some route may have been set for day 5
await act(async () => {
rerender({ dayId: null });
});
expect(result.current.route).toBeNull();
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-012: setRoute and setRouteInfo are exposed', () => {
const store = buildMockStore({});
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, null)
);
expect(result.current.setRoute).toBeTypeOf('function');
expect(result.current.setRouteInfo).toBeTypeOf('function');
});
it('FE-HOOK-ROUTE-013: hook uses tripStoreRef — late store updates reflected correctly', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
let storeData = buildMockStore({ '5': [a1, a2] });
const { result, rerender } = renderHook(() =>
useRouteCalculation(storeData as TripStoreState, 5)
);
await act(async () => {});
expect(result.current.route).toEqual([
[p1.lat, p1.lng],
[p2.lat, p2.lng],
]);
// Now add a third place
const p3 = buildPlace({ lat: 30, lng: 30 });
const a3 = buildAssignment({ day_id: 5, order_index: 2, place: p3 });
storeData = buildMockStore({ '5': [a1, a2, a3] });
await act(async () => {
rerender();
});
await act(async () => {});
expect(result.current.route).toEqual([
[p1.lat, p1.lng],
[p2.lat, p2.lng],
[p3.lat, p3.lng],
]);
});
});
@@ -0,0 +1,134 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useTripWebSocket } from '../../../src/hooks/useTripWebSocket';
import { useTripStore } from '../../../src/store/tripStore';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => 'mock-socket-id'),
joinTrip: vi.fn(),
leaveTrip: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
setRefetchCallback: vi.fn(),
}));
// Import the mocked module AFTER vi.mock
const wsMock = await import('../../../src/api/websocket');
describe('useTripWebSocket', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('FE-HOOK-WS-001: on mount, joinTrip(tripId) is called', () => {
const { unmount } = renderHook(() => useTripWebSocket(42));
expect(wsMock.joinTrip).toHaveBeenCalledWith(42);
unmount();
});
it('FE-HOOK-WS-002: on mount, addListener is called (registers event handlers)', () => {
const { unmount } = renderHook(() => useTripWebSocket(42));
// addListener is called twice: once for handleRemoteEvent, once for collabFileSync
expect(wsMock.addListener).toHaveBeenCalled();
expect((wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
unmount();
});
it('FE-HOOK-WS-003: on unmount, leaveTrip(tripId) is called', () => {
const { unmount } = renderHook(() => useTripWebSocket(42));
unmount();
expect(wsMock.leaveTrip).toHaveBeenCalledWith(42);
});
it('FE-HOOK-WS-004: on unmount, removeListener is called', () => {
const { unmount } = renderHook(() => useTripWebSocket(42));
unmount();
expect(wsMock.removeListener).toHaveBeenCalled();
});
it('FE-HOOK-WS-005: when tripId changes, leaves old trip and joins new one', () => {
const { rerender, unmount } = renderHook(({ id }) => useTripWebSocket(id), {
initialProps: { id: 1 as number | undefined },
});
expect(wsMock.joinTrip).toHaveBeenCalledWith(1);
rerender({ id: 2 });
expect(wsMock.leaveTrip).toHaveBeenCalledWith(1);
expect(wsMock.joinTrip).toHaveBeenCalledWith(2);
unmount();
});
it('FE-HOOK-WS-006: one of the registered listeners is handleRemoteEvent from tripStore', () => {
const handler = useTripStore.getState().handleRemoteEvent;
renderHook(() => useTripWebSocket(42));
const addListenerCalls = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls;
const registeredFunctions = addListenerCalls.map((call) => call[0]);
expect(registeredFunctions).toContain(handler);
});
it('FE-HOOK-WS-006b: collab file sync listener is also registered (second addListener call)', () => {
const { unmount } = renderHook(() => useTripWebSocket(42));
// Two listeners registered: handleRemoteEvent + collabFileSync
expect((wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
unmount();
});
it('FE-HOOK-WS-006c: collab file sync listener reacts to collab:note:deleted events', () => {
const mockLoadFiles = vi.fn();
useTripStore.setState({ loadFiles: mockLoadFiles } as any);
renderHook(() => useTripWebSocket(42));
// The second addListener call is the collabFileSync function
const addListenerCalls = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls;
const collabFileSync = addListenerCalls[1]?.[0];
expect(collabFileSync).toBeTypeOf('function');
act(() => {
collabFileSync({ type: 'collab:note:deleted' });
});
expect(mockLoadFiles).toHaveBeenCalledWith(42);
});
it('FE-HOOK-WS-006d: collab file sync listener reacts to collab:note:updated events', () => {
const mockLoadFiles = vi.fn();
useTripStore.setState({ loadFiles: mockLoadFiles } as any);
renderHook(() => useTripWebSocket(42));
const addListenerCalls = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls;
const collabFileSync = addListenerCalls[1]?.[0];
act(() => {
collabFileSync({ type: 'collab:note:updated' });
});
expect(mockLoadFiles).toHaveBeenCalledWith(42);
});
it('FE-HOOK-WS-006e: collab file sync listener ignores unrelated event types', () => {
const mockLoadFiles = vi.fn();
useTripStore.setState({ loadFiles: mockLoadFiles } as any);
renderHook(() => useTripWebSocket(42));
const addListenerCalls = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls;
const collabFileSync = addListenerCalls[1]?.[0];
act(() => {
collabFileSync({ type: 'place:created' });
});
expect(mockLoadFiles).not.toHaveBeenCalled();
});
it('FE-HOOK-WS-007: no joinTrip call when tripId is undefined', () => {
renderHook(() => useTripWebSocket(undefined));
expect(wsMock.joinTrip).not.toHaveBeenCalled();
});
});