From e3a5bc0f774768f3f79e9e1b568608cb8dfebcbc Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Apr 2026 02:22:02 +0200 Subject: [PATCH] fix(tests): mock FormData uploads at API boundary to fix CI timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsdom's FormData is incompatible with undici's ReadableStream serialisation used by MSW 2.x — requests hang under CI resource constraints but pass locally. Replace server.use() + implicit HTTP roundtrip with vi.spyOn().mockResolvedValueOnce() for all five FormData POST tests (uploadAvatar, uploadRestore, addFile, importGpx). --- .../components/Planner/PlacesSidebar.test.tsx | 9 ++++----- client/tests/integration/api/client.test.ts | 20 +++++++------------ client/tests/unit/slices/filesSlice.test.ts | 7 ++++--- client/tests/unit/stores/authStore.test.ts | 9 ++++----- 4 files changed, 19 insertions(+), 26 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index ba1557e6..79b52c17 100644 --- a/client/src/components/Planner/PlacesSidebar.test.tsx +++ b/client/src/components/Planner/PlacesSidebar.test.tsx @@ -5,6 +5,7 @@ import { http, HttpResponse } from 'msw'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { usePermissionsStore } from '../../store/permissionsStore'; +import { placesApi } from '../../api/client'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories'; import { server } from '../../../tests/helpers/msw/server'; @@ -443,11 +444,8 @@ describe('GPX import', () => { }); it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => { - server.use( - http.post('/api/trips/1/places/import/gpx', () => - HttpResponse.json({ count: 2, places: [{ id: 10 }, { id: 11 }] }) - ), - ); + // FormData POST hangs on CI — mock at the API boundary instead of MSW. + const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] }); const loadTrip = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { loadTrip }); const addToast = vi.fn(); @@ -465,6 +463,7 @@ describe('GPX import', () => { undefined, ); }); + importSpy.mockRestore(); }); }); diff --git a/client/tests/integration/api/client.test.ts b/client/tests/integration/api/client.test.ts index 4bc41a76..b7c081e0 100644 --- a/client/tests/integration/api/client.test.ts +++ b/client/tests/integration/api/client.test.ts @@ -466,18 +466,10 @@ describe('API client interceptors', () => { }); it('FE-API-022: authApi.uploadAvatar sends multipart/form-data', async () => { - // jsdom's FormData ≠ undici's FormData, so Node.js's Request always - // serialises it as text/plain — Content-Type header checks are unreliable. - // MSW wraps XHR in a Proxy, so XHR prototype spies never fire. axios.create() - // copies prototype methods onto the instance as bound functions, so prototype - // spies don't fire either. Spy on the exported apiClient instance directly. - server.use( - http.post('/api/auth/avatar', () => { - return HttpResponse.json({ avatar_url: '/uploads/avatar.jpg' }); - }) - ); - - const postSpy = vi.spyOn(apiClient, 'post'); + // 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'); @@ -894,9 +886,11 @@ describe('API namespace smoke tests', () => { }); it('backupApi.uploadRestore uploads and restores a backup', async () => { - server.use(http.post('/api/backup/upload-restore', () => HttpResponse.json({ ok: true }))); + // 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 () => { diff --git a/client/tests/unit/slices/filesSlice.test.ts b/client/tests/unit/slices/filesSlice.test.ts index 97de5cd9..78bcb9b5 100644 --- a/client/tests/unit/slices/filesSlice.test.ts +++ b/client/tests/unit/slices/filesSlice.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { useTripStore } from '../../../src/store/tripStore'; +import { filesApi } from '../../../src/api/client'; import { resetAllStores, seedStore } from '../../helpers/store'; import { buildTripFile } from '../../helpers/factories'; import { server } from '../../helpers/msw/server'; @@ -56,14 +57,14 @@ describe('filesSlice', () => { seedStore(useTripStore, { files: [existing] }); const uploaded = buildTripFile({ trip_id: 1, filename: 'new-upload.pdf' }); - server.use( - http.post('/api/trips/1/files', () => HttpResponse.json({ file: uploaded })), - ); + // FormData POST hangs on CI — mock at the API boundary instead of MSW. + const uploadSpy = vi.spyOn(filesApi, 'upload').mockResolvedValueOnce({ file: uploaded }); const formData = new FormData(); formData.append('file', new Blob(['content'], { type: 'application/pdf' }), 'new-upload.pdf'); const result = await useTripStore.getState().addFile(1, formData); + uploadSpy.mockRestore(); expect(result.filename).toBe('new-upload.pdf'); const files = useTripStore.getState().files; diff --git a/client/tests/unit/stores/authStore.test.ts b/client/tests/unit/stores/authStore.test.ts index f9e555d4..5b26919a 100644 --- a/client/tests/unit/stores/authStore.test.ts +++ b/client/tests/unit/stores/authStore.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { server } from '../../helpers/msw/server'; import { useAuthStore } from '../../../src/store/authStore'; +import { authApi } from '../../../src/api/client'; import { resetAllStores } from '../../helpers/store'; import { buildUser } from '../../helpers/factories'; @@ -425,11 +426,8 @@ describe('authStore', () => { describe('FE-STORE-AUTH-UPLOAD: uploadAvatar', () => { it('updates avatar_url from response', async () => { - server.use( - http.post('/api/auth/avatar', () => - HttpResponse.json({ avatar_url: '/uploads/avatar-new.png' }) - ) - ); + // FormData POST hangs on CI — mock at the API boundary instead of MSW. + const uploadSpy = vi.spyOn(authApi, 'uploadAvatar').mockResolvedValueOnce({ avatar_url: '/uploads/avatar-new.png' }); useAuthStore.setState({ user: buildUser() }); @@ -438,6 +436,7 @@ describe('authStore', () => { expect(result.avatar_url).toBe('/uploads/avatar-new.png'); expect(useAuthStore.getState().user?.avatar_url).toBe('/uploads/avatar-new.png'); + uploadSpy.mockRestore(); }); }); });