test(mobile): blindage contrat API — suite apiClient + durcissement INVALID_RESPONSE

- 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_<status> ; 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)
This commit is contained in:
m
2026-09-10 20:16:27 +02:00
parent d5886c678b
commit eddab392a2
3 changed files with 226 additions and 3 deletions
+31 -2
View File
@@ -72,22 +72,51 @@ async function request<T>(
const body = (await response.json().catch(() => null)) as const body = (await response.json().catch(() => null)) as
| ApiData<T> | ApiData<T>
| ApiErrorBody | ApiErrorBody
| unknown[]
| string
| number
| null; | null;
if ( !response.ok ) { 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( throw new ApiError(
error?.code ?? `HTTP_${response.status}`, error?.code ?? `HTTP_${response.status}`,
error?.message ?? response.statusText error?.message ?? response.statusText
); );
} }
return (body ?? { data: undefined as T }) as ApiData<T>; // 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<T>;
} finally { } finally {
clearTimeout(timer); 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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export const api = { export const api = {
getApiBaseUrl: () => API_BASE_URL, getApiBaseUrl: () => API_BASE_URL,
+3 -1
View File
@@ -34,7 +34,9 @@
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"test:db": "tsx --test tests/migrations.test.ts tests/repositories.test.ts", "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 "private": true
} }
+192
View File
@@ -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> | 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('<html>Bad Gateway</html>', { 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('<html>502</html>', { 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');
});