Files
TREK/client/tests/integration/api/client.test.ts
jubnl 607498cabe fix(search-autocomplete): address PR #542 review issues
- Fix race condition: AbortController cancels in-flight autocomplete
  requests on each keystroke; stale responses no longer overwrite fresh ones
- Remove acTrigger state hack; onFocus calls fetchSuggestions directly
- Cap autocomplete input at 200 chars server-side (400 on violation)
- Filter Nominatim suggestions with empty osm_id segments
- Revert getPlaceDetails OSM branch from unconditional parallel fetch to
  conditional serial: Nominatim called only when Overpass lacks coords/address
- Wire places.loadingDetails i18n key to Loader2 spinner via aria-label/role
- Add tests: MAPS-017, MAPS-040c, MAPS-093, FE-MAPS-004
2026-04-15 04:16:56 +02:00

975 lines
39 KiB
TypeScript

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(),
setPreReconnectHook: 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 {
apiClient,
authApi,
tripsApi,
placesApi,
packingApi,
inAppNotificationsApi,
shareApi,
backupApi,
daysApi,
assignmentsApi,
tagsApi,
categoriesApi,
adminApi,
addonsApi,
mapsApi,
budgetApi,
filesApi,
reservationsApi,
weatherApi,
settingsApi,
accommodationsApi,
dayNotesApi,
collabApi,
notificationsApi,
} = 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();
});
// ── 401 edge cases ───────────────────────────────────────────────────────────
it('FE-API-008: 401 AUTH_REQUIRED on /register path does not redirect', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/register', pathname: '/register', search: '' },
});
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 });
})
);
try { await authApi.me(); } catch { /* expected */ }
expect(window.location.href).toBe('http://localhost/register');
});
it('FE-API-009: 401 AUTH_REQUIRED on /shared/:token path does not redirect', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/shared/abc123', pathname: '/shared/abc123', search: '' },
});
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 });
})
);
try { await authApi.me(); } catch { /* expected */ }
expect(window.location.href).toBe('http://localhost/shared/abc123');
});
it('FE-API-010: 401 AUTH_REQUIRED still rejects the promise even when redirect fires', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '' },
});
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 });
})
);
await expect(authApi.me()).rejects.toThrow();
});
// ── 403 edge cases ───────────────────────────────────────────────────────────
it('FE-API-011: 403 without MFA_REQUIRED code does not redirect', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '' },
});
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ error: 'Forbidden' }, { status: 403 });
})
);
try { await authApi.me(); } catch { /* expected */ }
expect(window.location.href).toBe('http://localhost/dashboard');
});
it('FE-API-012: 403 MFA_REQUIRED still rejects the promise after redirect fires', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '' },
});
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 });
})
);
await expect(authApi.me()).rejects.toThrow();
});
// ── backupApi.download ───────────────────────────────────────────────────────
it('FE-API-013: backupApi.download creates a temp anchor and clicks it', async () => {
// backupApi.download uses native fetch (not axios). Mock fetch directly and
// use a plain-object Response duck-type to avoid MSW patching the Response
// constructor (which calls blob.stream() — not implemented in jsdom's Blob).
const blob = new Blob(['zip-bytes'], { type: 'application/zip' });
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: true,
blob: () => Promise.resolve(blob),
} as unknown as Response);
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
// Spy on createElement to intercept the anchor click
const originalCreate = document.createElement.bind(document);
const clickSpy = vi.fn();
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = originalCreate(tag);
if (tag === 'a') {
Object.defineProperty(el, 'click', { writable: true, value: clickSpy });
}
return el;
});
await expect(backupApi.download('backup.zip')).resolves.toBeUndefined();
expect(createObjectURL).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalled();
vi.restoreAllMocks();
});
it('FE-API-014: backupApi.download throws when response is not ok', async () => {
server.use(
http.get('/api/backup/download/missing.zip', () => {
return new HttpResponse(null, { status: 404 });
})
);
await expect(backupApi.download('missing.zip')).rejects.toThrow('Download failed');
});
// ── API namespace URL spot-checks ────────────────────────────────────────────
it('FE-API-015: tripsApi.list() makes GET to /api/trips', async () => {
server.use(
http.get('/api/trips', () => HttpResponse.json([]))
);
const result = await tripsApi.list();
expect(result).toEqual([]);
});
it('FE-API-016: tripsApi.get(42) makes GET to /api/trips/42', async () => {
let hitUrl = '';
server.use(
http.get('/api/trips/42', ({ request }) => {
hitUrl = new URL(request.url).pathname;
return HttpResponse.json({ id: 42 });
})
);
await tripsApi.get(42);
expect(hitUrl).toBe('/api/trips/42');
});
it('FE-API-017: placesApi.create posts to /api/trips/1/places and returns data directly', async () => {
const place = { id: 1, name: 'Paris', trip_id: 1 };
server.use(
http.post('/api/trips/1/places', () => HttpResponse.json(place))
);
const result = await placesApi.create(1, { name: 'Paris' });
expect(result).toMatchObject({ name: 'Paris' });
});
it('FE-API-018: packingApi.bulkImport posts correct payload', async () => {
let receivedBody: unknown;
server.use(
http.post('/api/trips/1/packing/import', async ({ request }) => {
receivedBody = await request.json();
return HttpResponse.json({ imported: 1 });
})
);
await packingApi.bulkImport(1, [{ name: 'Sunscreen' }]);
expect(receivedBody).toMatchObject({ items: [{ name: 'Sunscreen' }] });
});
it('FE-API-019: inAppNotificationsApi.list passes unread_only query param', async () => {
let searchParams: URLSearchParams | null = null;
server.use(
http.get('/api/notifications/in-app', ({ request }) => {
searchParams = new URL(request.url).searchParams;
return HttpResponse.json([]);
})
);
await inAppNotificationsApi.list({ unread_only: true });
expect(searchParams?.get('unread_only')).toBe('true');
});
it('FE-API-020: shareApi.getSharedTrip hits /api/shared/tok123', async () => {
let hitPath = '';
server.use(
http.get('/api/shared/tok123', ({ request }) => {
hitPath = new URL(request.url).pathname;
return HttpResponse.json({ token: 'tok123' });
})
);
const result = await shareApi.getSharedTrip('tok123');
expect(hitPath).toBe('/api/shared/tok123');
expect(result).toMatchObject({ token: 'tok123' });
});
// ── authApi method spot-checks ───────────────────────────────────────────────
it('FE-API-021: authApi.login posts email and password to /api/auth/login', async () => {
const user = buildUser();
let receivedBody: unknown;
server.use(
http.post('/api/auth/login', async ({ request }) => {
receivedBody = await request.json();
return HttpResponse.json({ user });
})
);
const result = await authApi.login({ email: 'a@b.com', password: 'pass' });
expect(receivedBody).toMatchObject({ email: 'a@b.com', password: 'pass' });
expect(result).toMatchObject({ user: { id: user.id } });
});
it('FE-API-022: authApi.uploadAvatar sends multipart/form-data', async () => {
// jsdom's FormData ≠ undici's FormData — MSW body serialisation of FormData
// hangs under CI resource constraints. Spy + mock at the axios level to verify
// the correct args are passed without going through the network stack.
const postSpy = vi.spyOn(apiClient, 'post').mockResolvedValueOnce({ data: { avatar_url: '/uploads/avatar.jpg' } } as any);
const formData = new FormData();
formData.append('avatar', new Blob(['img'], { type: 'image/jpeg' }), 'avatar.jpg');
await authApi.uploadAvatar(formData);
expect(postSpy).toHaveBeenCalledWith('/auth/avatar', expect.any(FormData), expect.anything());
postSpy.mockRestore();
});
it('FE-API-023: authApi.mcpTokens.create posts name to /api/auth/mcp-tokens', async () => {
let receivedBody: unknown;
server.use(
http.post('/api/auth/mcp-tokens', async ({ request }) => {
receivedBody = await request.json();
return HttpResponse.json({ id: 1, name: 'My Token', token: 'tok' });
})
);
await authApi.mcpTokens.create('My Token');
expect(receivedBody).toMatchObject({ name: 'My Token' });
});
});
describe('API namespace smoke tests', () => {
it('daysApi.list fetches trip days', async () => {
server.use(http.get('/api/trips/1/days', () => HttpResponse.json([])));
await expect(daysApi.list(1)).resolves.toEqual([]);
});
it('assignmentsApi.list fetches day assignments', async () => {
server.use(http.get('/api/trips/1/days/1/assignments', () => HttpResponse.json([])));
await expect(assignmentsApi.list(1, 1)).resolves.toEqual([]);
});
it('tagsApi.list fetches tags', async () => {
server.use(http.get('/api/tags', () => HttpResponse.json([])));
await expect(tagsApi.list()).resolves.toEqual([]);
});
it('categoriesApi.list fetches categories', async () => {
server.use(http.get('/api/categories', () => HttpResponse.json([])));
await expect(categoriesApi.list()).resolves.toEqual([]);
});
it('adminApi.users fetches admin users', async () => {
server.use(http.get('/api/admin/users', () => HttpResponse.json([])));
await expect(adminApi.users()).resolves.toEqual([]);
});
it('addonsApi.enabled fetches enabled addons', async () => {
server.use(http.get('/api/addons', () => HttpResponse.json([])));
await expect(addonsApi.enabled()).resolves.toEqual([]);
});
it('mapsApi.search posts query', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({ results: [] })));
await expect(mapsApi.search('Paris')).resolves.toMatchObject({ results: [] });
});
it('budgetApi.list fetches budget items', async () => {
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json([])));
await expect(budgetApi.list(1)).resolves.toEqual([]);
});
it('filesApi.list fetches trip files', async () => {
server.use(http.get('/api/trips/1/files', () => HttpResponse.json([])));
await expect(filesApi.list(1)).resolves.toEqual([]);
});
it('reservationsApi.list fetches reservations', async () => {
server.use(http.get('/api/trips/1/reservations', () => HttpResponse.json([])));
await expect(reservationsApi.list(1)).resolves.toEqual([]);
});
it('weatherApi.get fetches weather data', async () => {
server.use(http.get('/api/weather', () => HttpResponse.json({ temp: 20 })));
await expect(weatherApi.get(48.8, 2.3, '2025-06-01')).resolves.toMatchObject({ temp: 20 });
});
it('settingsApi.get fetches settings', async () => {
server.use(http.get('/api/settings', () => HttpResponse.json({ dark_mode: false })));
await expect(settingsApi.get()).resolves.toMatchObject({ dark_mode: false });
});
it('accommodationsApi.list fetches accommodations', async () => {
server.use(http.get('/api/trips/1/accommodations', () => HttpResponse.json([])));
await expect(accommodationsApi.list(1)).resolves.toEqual([]);
});
it('dayNotesApi.list fetches day notes', async () => {
server.use(http.get('/api/trips/1/days/1/notes', () => HttpResponse.json([])));
await expect(dayNotesApi.list(1, 1)).resolves.toEqual([]);
});
it('collabApi.getNotes fetches collab notes', async () => {
server.use(http.get('/api/trips/1/collab/notes', () => HttpResponse.json([])));
await expect(collabApi.getNotes(1)).resolves.toEqual([]);
});
it('notificationsApi.getPreferences fetches preferences', async () => {
server.use(http.get('/api/notifications/preferences', () => HttpResponse.json({ email: true })));
await expect(notificationsApi.getPreferences()).resolves.toMatchObject({ email: true });
});
it('inAppNotificationsApi.unreadCount fetches unread count', async () => {
server.use(http.get('/api/notifications/in-app/unread-count', () => HttpResponse.json({ count: 3 })));
await expect(inAppNotificationsApi.unreadCount()).resolves.toMatchObject({ count: 3 });
});
it('inAppNotificationsApi.markRead marks a notification read', async () => {
server.use(http.put('/api/notifications/in-app/5/read', () => HttpResponse.json({ ok: true })));
await expect(inAppNotificationsApi.markRead(5)).resolves.toMatchObject({ ok: true });
});
it('inAppNotificationsApi.markAllRead marks all notifications read', async () => {
server.use(http.put('/api/notifications/in-app/read-all', () => HttpResponse.json({ ok: true })));
await expect(inAppNotificationsApi.markAllRead()).resolves.toMatchObject({ ok: true });
});
it('inAppNotificationsApi.delete deletes a notification', async () => {
server.use(http.delete('/api/notifications/in-app/5', () => HttpResponse.json({ ok: true })));
await expect(inAppNotificationsApi.delete(5)).resolves.toMatchObject({ ok: true });
});
it('inAppNotificationsApi.markUnread marks a notification unread', async () => {
server.use(http.put('/api/notifications/in-app/5/unread', () => HttpResponse.json({ ok: true })));
await expect(inAppNotificationsApi.markUnread(5)).resolves.toMatchObject({ ok: true });
});
it('inAppNotificationsApi.deleteAll deletes all notifications', async () => {
server.use(http.delete('/api/notifications/in-app/all', () => HttpResponse.json({ ok: true })));
await expect(inAppNotificationsApi.deleteAll()).resolves.toMatchObject({ ok: true });
});
it('inAppNotificationsApi.respond posts a response', async () => {
server.use(http.post('/api/notifications/in-app/5/respond', () => HttpResponse.json({ ok: true })));
await expect(inAppNotificationsApi.respond(5, 'positive')).resolves.toMatchObject({ ok: true });
});
it('notificationsApi.updatePreferences updates preferences', async () => {
server.use(http.put('/api/notifications/preferences', () => HttpResponse.json({ ok: true })));
await expect(notificationsApi.updatePreferences({ email: { trip_invite: true } })).resolves.toMatchObject({ ok: true });
});
it('backupApi.list fetches backup list', async () => {
server.use(http.get('/api/backup/list', () => HttpResponse.json([])));
await expect(backupApi.list()).resolves.toEqual([]);
});
// ── tripsApi additional methods ──────────────────────────────────────────────
it('tripsApi.create posts new trip', async () => {
server.use(http.post('/api/trips', () => HttpResponse.json({ id: 1, name: 'Test' })));
await expect(tripsApi.create({ name: 'Test' })).resolves.toMatchObject({ id: 1 });
});
it('tripsApi.update puts trip data', async () => {
server.use(http.put('/api/trips/1', () => HttpResponse.json({ id: 1 })));
await expect(tripsApi.update(1, { name: 'Updated' })).resolves.toMatchObject({ id: 1 });
});
it('tripsApi.delete deletes a trip', async () => {
server.use(http.delete('/api/trips/1', () => HttpResponse.json({ ok: true })));
await expect(tripsApi.delete(1)).resolves.toMatchObject({ ok: true });
});
it('tripsApi.getMembers fetches trip members', async () => {
server.use(http.get('/api/trips/1/members', () => HttpResponse.json([])));
await expect(tripsApi.getMembers(1)).resolves.toEqual([]);
});
it('tripsApi.copy copies a trip', async () => {
server.use(http.post('/api/trips/1/copy', () => HttpResponse.json({ id: 99 })));
await expect(tripsApi.copy(1)).resolves.toMatchObject({ id: 99 });
});
// ── placesApi additional methods ─────────────────────────────────────────────
it('placesApi.list fetches places', async () => {
server.use(http.get('/api/trips/1/places', () => HttpResponse.json([])));
await expect(placesApi.list(1)).resolves.toEqual([]);
});
it('placesApi.get fetches a place', async () => {
server.use(http.get('/api/trips/1/places/5', () => HttpResponse.json({ id: 5 })));
await expect(placesApi.get(1, 5)).resolves.toMatchObject({ id: 5 });
});
it('placesApi.update updates a place', async () => {
server.use(http.put('/api/trips/1/places/5', () => HttpResponse.json({ id: 5 })));
await expect(placesApi.update(1, 5, { name: 'Rome' })).resolves.toMatchObject({ id: 5 });
});
it('placesApi.delete deletes a place', async () => {
server.use(http.delete('/api/trips/1/places/5', () => HttpResponse.json({ ok: true })));
await expect(placesApi.delete(1, 5)).resolves.toMatchObject({ ok: true });
});
// ── packingApi additional methods ────────────────────────────────────────────
it('packingApi.list fetches packing items', async () => {
server.use(http.get('/api/trips/1/packing', () => HttpResponse.json([])));
await expect(packingApi.list(1)).resolves.toEqual([]);
});
it('packingApi.create creates a packing item', async () => {
server.use(http.post('/api/trips/1/packing', () => HttpResponse.json({ id: 1, name: 'Towel' })));
await expect(packingApi.create(1, { name: 'Towel' })).resolves.toMatchObject({ id: 1 });
});
it('packingApi.delete deletes a packing item', async () => {
server.use(http.delete('/api/trips/1/packing/1', () => HttpResponse.json({ ok: true })));
await expect(packingApi.delete(1, 1)).resolves.toMatchObject({ ok: true });
});
// ── assignmentsApi additional methods ────────────────────────────────────────
it('assignmentsApi.create creates an assignment', async () => {
server.use(http.post('/api/trips/1/days/1/assignments', () => HttpResponse.json({ id: 1 })));
await expect(assignmentsApi.create(1, 1, { place_id: 5 })).resolves.toMatchObject({ id: 1 });
});
it('assignmentsApi.delete deletes an assignment', async () => {
server.use(http.delete('/api/trips/1/days/1/assignments/1', () => HttpResponse.json({ ok: true })));
await expect(assignmentsApi.delete(1, 1, 1)).resolves.toMatchObject({ ok: true });
});
it('assignmentsApi.reorder reorders assignments', async () => {
server.use(http.put('/api/trips/1/days/1/assignments/reorder', () => HttpResponse.json({ ok: true })));
await expect(assignmentsApi.reorder(1, 1, [3, 1, 2])).resolves.toMatchObject({ ok: true });
});
// ── daysApi additional methods ───────────────────────────────────────────────
it('daysApi.create creates a day', async () => {
server.use(http.post('/api/trips/1/days', () => HttpResponse.json({ id: 1 })));
await expect(daysApi.create(1, { date: '2025-06-01' })).resolves.toMatchObject({ id: 1 });
});
it('daysApi.delete deletes a day', async () => {
server.use(http.delete('/api/trips/1/days/1', () => HttpResponse.json({ ok: true })));
await expect(daysApi.delete(1, 1)).resolves.toMatchObject({ ok: true });
});
// ── tagsApi / categoriesApi additional methods ────────────────────────────────
it('tagsApi.create creates a tag', async () => {
server.use(http.post('/api/tags', () => HttpResponse.json({ id: 1, name: 'Fun' })));
await expect(tagsApi.create({ name: 'Fun' })).resolves.toMatchObject({ id: 1 });
});
it('tagsApi.delete deletes a tag', async () => {
server.use(http.delete('/api/tags/1', () => HttpResponse.json({ ok: true })));
await expect(tagsApi.delete(1)).resolves.toMatchObject({ ok: true });
});
it('categoriesApi.create creates a category', async () => {
server.use(http.post('/api/categories', () => HttpResponse.json({ id: 1, name: 'Food' })));
await expect(categoriesApi.create({ name: 'Food' })).resolves.toMatchObject({ id: 1 });
});
it('categoriesApi.delete deletes a category', async () => {
server.use(http.delete('/api/categories/1', () => HttpResponse.json({ ok: true })));
await expect(categoriesApi.delete(1)).resolves.toMatchObject({ ok: true });
});
// ── adminApi additional methods ───────────────────────────────────────────────
it('adminApi.stats fetches admin stats', async () => {
server.use(http.get('/api/admin/stats', () => HttpResponse.json({ trips: 5 })));
await expect(adminApi.stats()).resolves.toMatchObject({ trips: 5 });
});
it('adminApi.createUser creates a user', async () => {
server.use(http.post('/api/admin/users', () => HttpResponse.json({ id: 10 })));
await expect(adminApi.createUser({ email: 'x@x.com' })).resolves.toMatchObject({ id: 10 });
});
// ── budgetApi additional methods ─────────────────────────────────────────────
it('budgetApi.create creates a budget item', async () => {
server.use(http.post('/api/trips/1/budget', () => HttpResponse.json({ id: 1 })));
await expect(budgetApi.create(1, { name: 'Hotel' })).resolves.toMatchObject({ id: 1 });
});
it('budgetApi.delete deletes a budget item', async () => {
server.use(http.delete('/api/trips/1/budget/1', () => HttpResponse.json({ ok: true })));
await expect(budgetApi.delete(1, 1)).resolves.toMatchObject({ ok: true });
});
// ── reservationsApi additional methods ───────────────────────────────────────
it('reservationsApi.create creates a reservation', async () => {
server.use(http.post('/api/trips/1/reservations', () => HttpResponse.json({ id: 1 })));
await expect(reservationsApi.create(1, { name: 'Hotel' })).resolves.toMatchObject({ id: 1 });
});
it('reservationsApi.delete deletes a reservation', async () => {
server.use(http.delete('/api/trips/1/reservations/1', () => HttpResponse.json({ ok: true })));
await expect(reservationsApi.delete(1, 1)).resolves.toMatchObject({ ok: true });
});
// ── settingsApi additional methods ───────────────────────────────────────────
it('settingsApi.set updates a setting', async () => {
server.use(http.put('/api/settings', () => HttpResponse.json({ ok: true })));
await expect(settingsApi.set('dark_mode', true)).resolves.toMatchObject({ ok: true });
});
// ── accommodationsApi additional methods ─────────────────────────────────────
it('accommodationsApi.create creates accommodation', async () => {
server.use(http.post('/api/trips/1/accommodations', () => HttpResponse.json({ id: 1 })));
await expect(accommodationsApi.create(1, { name: 'Hotel' })).resolves.toMatchObject({ id: 1 });
});
it('accommodationsApi.delete deletes accommodation', async () => {
server.use(http.delete('/api/trips/1/accommodations/1', () => HttpResponse.json({ ok: true })));
await expect(accommodationsApi.delete(1, 1)).resolves.toMatchObject({ ok: true });
});
// ── dayNotesApi additional methods ───────────────────────────────────────────
it('dayNotesApi.create creates a day note', async () => {
server.use(http.post('/api/trips/1/days/1/notes', () => HttpResponse.json({ id: 1 })));
await expect(dayNotesApi.create(1, 1, { text: 'Hello' })).resolves.toMatchObject({ id: 1 });
});
it('dayNotesApi.delete deletes a day note', async () => {
server.use(http.delete('/api/trips/1/days/1/notes/1', () => HttpResponse.json({ ok: true })));
await expect(dayNotesApi.delete(1, 1, 1)).resolves.toMatchObject({ ok: true });
});
// ── collabApi additional methods ─────────────────────────────────────────────
it('collabApi.createNote creates a note', async () => {
server.use(http.post('/api/trips/1/collab/notes', () => HttpResponse.json({ id: 1 })));
await expect(collabApi.createNote(1, { title: 'Note' })).resolves.toMatchObject({ id: 1 });
});
it('collabApi.deleteNote deletes a note', async () => {
server.use(http.delete('/api/trips/1/collab/notes/1', () => HttpResponse.json({ ok: true })));
await expect(collabApi.deleteNote(1, 1)).resolves.toMatchObject({ ok: true });
});
// ── backupApi additional methods ─────────────────────────────────────────────
it('backupApi.getAutoSettings fetches auto backup settings', async () => {
server.use(http.get('/api/backup/auto-settings', () => HttpResponse.json({ enabled: true })));
await expect(backupApi.getAutoSettings()).resolves.toMatchObject({ enabled: true });
});
it('backupApi.delete deletes a backup', async () => {
server.use(http.delete('/api/backup/backup.zip', () => HttpResponse.json({ ok: true })));
await expect(backupApi.delete('backup.zip')).resolves.toMatchObject({ ok: true });
});
// ── shareApi additional methods ───────────────────────────────────────────────
it('shareApi.createLink creates a share link', async () => {
server.use(http.post('/api/trips/1/share-link', () => HttpResponse.json({ token: 'abc' })));
await expect(shareApi.createLink(1)).resolves.toMatchObject({ token: 'abc' });
});
it('shareApi.deleteLink deletes a share link', async () => {
server.use(http.delete('/api/trips/1/share-link', () => HttpResponse.json({ ok: true })));
await expect(shareApi.deleteLink(1)).resolves.toMatchObject({ ok: true });
});
// ── notificationsApi additional methods ───────────────────────────────────────
it('notificationsApi.testWebhook tests webhook endpoint', async () => {
server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ ok: true })));
await expect(notificationsApi.testWebhook('http://example.com')).resolves.toMatchObject({ ok: true });
});
it('notificationsApi.testSmtp tests smtp endpoint', async () => {
server.use(http.post('/api/notifications/test-smtp', () => HttpResponse.json({ ok: true })));
await expect(notificationsApi.testSmtp('user@example.com')).resolves.toMatchObject({ ok: true });
});
// ── mapsApi additional methods ────────────────────────────────────────────────
it('mapsApi.reverse fetches reverse geocode', async () => {
server.use(http.get('/api/maps/reverse', () => HttpResponse.json({ address: 'Paris' })));
await expect(mapsApi.reverse(48.8, 2.3)).resolves.toMatchObject({ address: 'Paris' });
});
// ── collabApi messaging methods ───────────────────────────────────────────────
it('collabApi.getMessages fetches messages', async () => {
server.use(http.get('/api/trips/1/collab/messages', () => HttpResponse.json([])));
await expect(collabApi.getMessages(1)).resolves.toEqual([]);
});
it('collabApi.sendMessage sends a message', async () => {
server.use(http.post('/api/trips/1/collab/messages', () => HttpResponse.json({ id: 1 })));
await expect(collabApi.sendMessage(1, { text: 'Hello' })).resolves.toMatchObject({ id: 1 });
});
it('collabApi.deleteMessage deletes a message', async () => {
server.use(http.delete('/api/trips/1/collab/messages/1', () => HttpResponse.json({ ok: true })));
await expect(collabApi.deleteMessage(1, 1)).resolves.toMatchObject({ ok: true });
});
it('collabApi.reactMessage reacts to a message', async () => {
server.use(http.post('/api/trips/1/collab/messages/1/react', () => HttpResponse.json({ ok: true })));
await expect(collabApi.reactMessage(1, 1, '👍')).resolves.toMatchObject({ ok: true });
});
it('collabApi.getPolls fetches polls', async () => {
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json([])));
await expect(collabApi.getPolls(1)).resolves.toEqual([]);
});
it('backupApi.uploadRestore uploads and restores a backup', async () => {
// FormData POST hangs on CI — mock at the axios level (see FE-API-022 comment).
const postSpy = vi.spyOn(apiClient, 'post').mockResolvedValueOnce({ data: { ok: true } } as any);
const file = new File(['data'], 'backup.zip', { type: 'application/zip' });
await expect(backupApi.uploadRestore(file)).resolves.toMatchObject({ ok: true });
postSpy.mockRestore();
});
it('backupApi.restore restores a named backup', async () => {
server.use(http.post('/api/backup/restore/backup.zip', () => HttpResponse.json({ ok: true })));
await expect(backupApi.restore('backup.zip')).resolves.toMatchObject({ ok: true });
});
it('backupApi.create creates a backup', async () => {
server.use(http.post('/api/backup/create', () => HttpResponse.json({ filename: 'backup.zip' })));
await expect(backupApi.create()).resolves.toMatchObject({ filename: 'backup.zip' });
});
});
describe('mapsApi', () => {
it('FE-MAPS-001: mapsApi.autocomplete sends input, lang, and locationBias', async () => {
let capturedBody: any = null;
server.use(
http.post('/api/maps/autocomplete', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
suggestions: [{ placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' }],
source: 'google',
});
})
);
const result = await mapsApi.autocomplete('Par', 'fr', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } });
expect(capturedBody).toEqual({
input: 'Par',
lang: 'fr',
locationBias: { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } },
});
expect(result.suggestions).toHaveLength(1);
expect(result.suggestions[0].mainText).toBe('Paris');
expect(result.source).toBe('google');
});
it('FE-MAPS-002: mapsApi.autocomplete works without optional params', async () => {
server.use(
http.post('/api/maps/autocomplete', async ({ request }) => {
const body: any = await request.json();
expect(body.lang).toBeUndefined();
expect(body.locationBias).toBeUndefined();
return HttpResponse.json({ suggestions: [], source: 'nominatim' });
})
);
const result = await mapsApi.autocomplete('test');
expect(result.suggestions).toEqual([]);
});
it('FE-MAPS-003: mapsApi.autocomplete rejects on server error', async () => {
server.use(
http.post('/api/maps/autocomplete', () => {
return HttpResponse.json({ error: 'Rate limited' }, { status: 429 });
})
);
await expect(mapsApi.autocomplete('test')).rejects.toThrow();
});
it('FE-MAPS-004: mapsApi.autocomplete rejects when AbortSignal is aborted', async () => {
const controller = new AbortController();
server.use(
http.post('/api/maps/autocomplete', async () => {
// Never resolves — request will be aborted
await new Promise(() => {});
return HttpResponse.json({ suggestions: [] });
})
);
const promise = mapsApi.autocomplete('Paris', undefined, undefined, controller.signal);
controller.abort();
await expect(promise).rejects.toThrow();
});
});