From eddab392a240a8d2357e20f97adecb57fe1d7197 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 10 Sep 2026 20:16:27 +0200 Subject: [PATCH] =?UTF-8?q?test(mobile):=20blindage=20contrat=20API=20?= =?UTF-8?q?=E2=80=94=20suite=20apiClient=20+=20durcissement=20INVALID=5FRE?= =?UTF-8?q?SPONSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/apiClient.test.ts (tsx/node:test, fetch stubbé) : paths/méthodes/query/body de tous les endpoints, multipart FormData sans Content-Type manuel, enveloppe {data,meta}, Bearer (présent/absent/fusion) - cas hostiles : 2xx HTML/corps vide/JSON invalide/objet sans data/error → ApiError INVALID_RESPONSE (jamais de TypeError ni data indéfini) ; bare 5xx → HTTP_ ; fetch rejeté (dont AbortError) → NETWORK_ERROR ; [] toléré en data - client.ts : parse d'enveloppe stricte dans request() (nouveau code client INVALID_RESPONSE) - package.json : scripts test:api + test (db + api) --- mobile/api/client.ts | 33 +++++- mobile/package.json | 4 +- mobile/tests/apiClient.test.ts | 192 +++++++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 mobile/tests/apiClient.test.ts diff --git a/mobile/api/client.ts b/mobile/api/client.ts index e9d93d5..ac4b618 100644 --- a/mobile/api/client.ts +++ b/mobile/api/client.ts @@ -72,22 +72,51 @@ async function request( const body = (await response.json().catch(() => null)) as | ApiData | ApiErrorBody + | unknown[] + | string + | number | null; if ( !response.ok ) { - const error = body != null && 'error' in body ? body.error : null; + const error = body != null && isErrorBody(body) ? body.error : null; throw new ApiError( error?.code ?? `HTTP_${response.status}`, error?.message ?? response.statusText ); } - return (body ?? { data: undefined as T }) as ApiData; + // 2xx : enveloppe `{ data }` obligatoire. Un corps illisible (HTML d'un + // proxy, JSON malformé, corps vide) ou un objet sans clé `data` est une + // réponse invalide → ApiError propre, jamais de TypeError ni de data + // indéfini silencieux. Les listes brutes (`[]`) restent tolérées. + if ( body === null || isErrorBody(body) ) { + throw new ApiError('INVALID_RESPONSE', 'Réponse serveur invalide (enveloppe {data} attendue)'); + } + if ( !isRecord(body) ) { + return { data: body as T }; + } + if ( !('data' in body) ) { + throw new ApiError('INVALID_RESPONSE', 'Réponse serveur invalide (enveloppe {data} attendue)'); + } + return body as ApiData; } finally { clearTimeout(timer); } } +function isErrorBody(body: unknown): body is ApiErrorBody { + return ( + typeof body === 'object' && + body !== null && + 'error' in body && + (body as { error?: { code?: unknown } }).error != null + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + export const api = { getApiBaseUrl: () => API_BASE_URL, diff --git a/mobile/package.json b/mobile/package.json index 5385aa3..cbb4d7b 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -34,7 +34,9 @@ "ios": "expo run:ios", "web": "expo start --web", "test:db": "tsx --test tests/migrations.test.ts tests/repositories.test.ts", - "test:migrations": "tsx --test tests/migrations.test.ts" + "test:migrations": "tsx --test tests/migrations.test.ts", + "test:api": "tsx --test tests/apiClient.test.ts", + "test": "npm run test:db && npm run test:api" }, "private": true } diff --git a/mobile/tests/apiClient.test.ts b/mobile/tests/apiClient.test.ts new file mode 100644 index 0000000..0556c89 --- /dev/null +++ b/mobile/tests/apiClient.test.ts @@ -0,0 +1,192 @@ +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { api, setAuthToken, ApiError } from '../api/client'; +import type { ApiData } from '../api/types'; + +function jsonResponse(status: number, body: unknown, headers?: HeadersInit): Response { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +function ok(data: unknown, meta?: unknown): Response { + return jsonResponse(200, { data, ...(meta !== undefined ? { meta } : {}) }); +} + +let calls: { url: string; init: RequestInit }[] = []; +const originalFetch = globalThis.fetch; + +function stubFetch(handler: (url: string, init: RequestInit) => Promise | Response): void { + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), init: init ?? {} }); + return handler(String(input), init ?? {}); + }; +} + +// URL absolue → chemin + query seulement (le prefix est EXPO_PUBLIC_API_BASE_URL) +function pathOf(url: string): string { + const u = new URL(url); + return u.pathname + u.search; +} + +beforeEach(() => { + calls = []; + setAuthToken(null); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test('nominal : GET /health', async () => { + stubFetch(() => ok({ status: 'healthy' })); + const res = await api.health(); + assert.equal(pathOf(calls[0].url), '/api/v1/health'); + assert.equal(calls[0].init.method ?? 'GET', 'GET'); + assert.deepEqual(res.data, { status: 'healthy' }); +}); + +test('nominal : POST /devices (register) JSON', async () => { + stubFetch(() => ok({ deviceId: 'aaaa', token: 'v4.local.x' })); + const res = await api.registerDevice('aaaa'); + assert.equal(pathOf(calls[0].url), '/api/v1/devices'); + assert.equal(calls[0].init.method, 'POST'); + assert.deepEqual(JSON.parse(String(calls[0].init.body)), { deviceId: 'aaaa' }); + assert.equal(res.data.token, 'v4.local.x'); +}); + +test('nominal : GET /files — query filtrée (undefined/null ignorés)', async () => { + stubFetch(() => ok([], { page: 1, pageSize: 50, total: 0 })); + const res = await api.listFiles({ folderId: 'abc', page: 2, pageSize: 50, sort: undefined }); + assert.equal(pathOf(calls[0].url), '/api/v1/files?folderId=abc&page=2&pageSize=50'); + assert.equal(calls[0].init.method ?? 'GET', 'GET'); + assert.deepEqual(res.data, []); + assert.equal(res.meta?.total, 0); +}); + +test('nominal : GET/DELETE /files/:id — id encodé', async () => { + stubFetch(() => ok({ id: 'a%2Fb' })); + await api.getFile('a/b'); + await api.deleteFile('a/b'); + assert.equal(pathOf(calls[0].url), '/api/v1/files/a%2Fb'); + assert.equal(pathOf(calls[1].url), '/api/v1/files/a%2Fb'); + assert.equal(calls[1].init.method, 'DELETE'); +}); + +test('nominal : GET /files/search?q=', async () => { + stubFetch(() => ok([])); + await api.searchFiles('rapport', { page: 1 }); + assert.equal(pathOf(calls[0].url), '/api/v1/files/search?q=rapport&page=1'); +}); + +test('nominal : GET /files/folders', async () => { + stubFetch(() => ok([{ id: 'f', name: 'Docs' }])); + const res = await api.listFolders(); + assert.equal(pathOf(calls[0].url), '/api/v1/files/folders'); + assert.equal(res.data[0].name, 'Docs'); +}); + +test('nominal : POST /files/upload — multipart FormData, pas de Content-Type manuel', async () => { + stubFetch(() => ok({ id: 'u', name: 'a.txt', size: 3 })); + await api.uploadFile({ uri: 'file:///a.txt', name: 'a.txt', mimeType: 'text/plain' }, 'folder1'); + assert.equal(pathOf(calls[0].url), '/api/v1/files/upload'); + assert.equal(calls[0].init.method, 'POST'); + const form = calls[0].init.body as FormData; + assert.ok(form instanceof FormData); + // RN transforme le pseudo-objet {uri,name,type} en part de fichier ; sous + // Node la célébration est stringifiée — on vérifie juste la présence. + assert.notEqual(form.get('file'), null, 'la part "file" doit être présente'); + assert.equal(form.get('folderId'), 'folder1'); + assert.equal(calls[0].init.headers, undefined, 'le client ne doit jamais fixer Content-Type'); +}); + +test('nominal : OCR create + get', async () => { + stubFetch(() => ok({ id: 'j', status: 'queued' })); + const created = await api.createOcrJob('file-1'); + assert.equal(pathOf(calls[0].url), '/api/v1/ocr/jobs'); + assert.equal(calls[0].init.method, 'POST'); + assert.deepEqual(JSON.parse(String(calls[0].init.body)), { fileId: 'file-1' }); + await api.getOcrJob('j'); + assert.equal(pathOf(calls[1].url), '/api/v1/ocr/jobs/j'); + assert.equal(created.data.status, 'queued'); +}); + +test('enveloppe : { data, meta } parsée intégralement', async () => { + stubFetch(() => ok({ id: 'x', name: 'n' }, { page: 1, pageSize: 10, total: 3 })); + const res: ApiData<{ id: string }> = await api.getFile('x'); + assert.deepEqual(res, { data: { id: 'x', name: 'n' }, meta: { page: 1, pageSize: 10, total: 3 } }); +}); + +test('hostile : 200 + HTML (proxy) → INVALID_RESPONSE', async () => { + stubFetch(() => new Response('Bad Gateway', { status: 200, headers: { 'Content-Type': 'text/html' } })); + await assert.rejects(() => api.health(), (err: unknown) => { + assert.ok(err instanceof ApiError); + assert.equal((err as ApiError).code, 'INVALID_RESPONSE'); + return true; + }); +}); + +test('hostile : 200 + corps vide → INVALID_RESPONSE', async () => { + stubFetch(() => new Response(null, { status: 200 })); + await assert.rejects(() => api.health(), (err: unknown) => (err as ApiError).code === 'INVALID_RESPONSE'); +}); + +test('hostile : 200 + JSON sans clé data → INVALID_RESPONSE', async () => { + stubFetch(() => jsonResponse(200, { foo: 1 })); + await assert.rejects(() => api.health(), (err: unknown) => (err as ApiError).code === 'INVALID_RESPONSE'); +}); + +test('hostile : 200 + enveloppe {error} → INVALID_RESPONSE', async () => { + stubFetch(() => jsonResponse(200, { error: { code: 'X', message: 'm' } })); + await assert.rejects(() => api.health(), (err: unknown) => (err as ApiError).code === 'INVALID_RESPONSE'); +}); + +test('liste brute : 200 + [] → data tolérée', async () => { + stubFetch(() => jsonResponse(200, [])); + const res = await api.listFolders(); + assert.deepEqual(res, { data: [] }); +}); + +test('erreur enveloppée : 400 {error.code} → code transmis', async () => { + stubFetch(() => jsonResponse(400, { error: { code: 'INVALID_DEVICE_ID', message: 'bad' } })); + await assert.rejects(() => api.registerDevice('zz'), (err: unknown) => { + const e = err as ApiError; + return e.code === 'INVALID_DEVICE_ID' && e.message === 'bad'; + }); +}); + +test('erreur nue : 502 HTML → HTTP_502', async () => { + stubFetch(() => new Response('502', { status: 502, headers: { 'Content-Type': 'text/html' } })); + await assert.rejects(() => api.health(), (err: unknown) => (err as ApiError).code === 'HTTP_502'); +}); + +test('réseau : fetch rejette (dont timeout/AbortError) → NETWORK_ERROR', async () => { + stubFetch(() => { + throw new DOMException('The operation was aborted.', 'AbortError'); + }); + await assert.rejects(() => api.health(), (err: unknown) => (err as ApiError).code === 'NETWORK_ERROR'); +}); + +test('auth : sans token, pas de header Authorization', async () => { + stubFetch(() => ok([])); + await api.listFiles(); + assert.equal(calls[0].init.headers, undefined); +}); + +test('auth : token posé → Authorization: Bearer', async () => { + setAuthToken('tok-123'); + stubFetch(() => ok([])); + await api.listFiles(); + const headers = new Headers(calls[0].init.headers); + assert.equal(headers.get('Authorization'), 'Bearer tok-123'); +}); + +test('auth : fusion avec Content-Type existant (POST /devices + token)', async () => { + setAuthToken('tok-123'); + stubFetch(() => ok({ deviceId: 'a', token: 't' })); + await api.registerDevice('a'); + const headers = new Headers(calls[0].init.headers); + assert.equal(headers.get('Authorization'), 'Bearer tok-123'); + assert.equal(headers.get('Content-Type'), 'application/json'); +}); \ No newline at end of file