Files
Kazier/mobile/tests/e2e.live.test.ts
T
m 197c7db8a9 feat(mobile): boucle sync client↔serveur — outbox push + snapshot permissions
- api/types.ts : SyncOperation, SyncResult (sémantique `applied` = INDEX documentée),
  ResourcePermission (miroir docs/api-v1.md §6, consommé tel quel par saveResourcePermission)
- api/client.ts : syncOps (POST /sync/ops), getSyncPermissions (GET /sync/permissions?after=),
  hasAuthToken() ; INVALID_RESPONSE documenté dans docs/api-v1.md §1/§4
- services/db/repositories/pendingOps.ts : listQueuedOperations(limit) (batch FIFO due)
  + scheduleRetries(ids, retryAt) qui ne modifie JAMAIS attempts (erreur transitoire,
  contrairement à markPendingOperation('failed') qui incrémente + backoff)
- features/syncOutbox.ts (nouveau) :
  * pushPendingOps : batch ≤ SYNC_BATCH_SIZE (50) → ops [0, applied) passent 'completed' ;
    op à l'index applied refusée → 'failed' (backoff, dead-letter MAX_PENDING_ATTEMPTS) ;
    ops suivantes intactes (re-soumission) ; réseau/5xx → scheduleRetries (retried) sans
    dead-letter prématurée ; applied==0 && failed → rien commité, aucun compteur touché
  * refreshPermissions : delta monotone en mémoire lastPermissionCachedAt → `after`,
    upsert saveResourcePermission ; no-op sans token
- features/syncDevice.ts : le tick useSyncDevice() enchaîne SAF walk → pushPendingOps()
  → refreshPermissions(), tous deux gardés par hasAuthToken()
- tests/syncOutbox.test.ts : 12 cas (FIFO/limit, succès complet/partiel, applied=0,
  transitoire réseau + 5xx sans incrément, dead-letter après MAX_PENDING_ATTEMPTS,
  delta snapshot croisé) — npm run test:sync
- tests/e2e.live.test.ts : smoke real backend (register → push → relecture /files/folders →
  snapshot), skip si serveur down, SORTI de npm test via npm run test:e2e
- gates : npx tsc --noEmit + npm run test → 56/56 verts (24 db + 20 api + 12 sync)
2026-09-10 20:23:55 +02:00

121 lines
3.8 KiB
TypeScript

// Smoke test live contre un backend démarré (POSTGRES + serveur Go) :
// docker compose up postgres -d (racine repo)
// cd backend && go run cmd/server/main.go
// cd mobile && npm run test:e2e
//
// NON agrégé dans `npm test` (il exige un backend fonctionnel). Si le serveur
// est injoignable, les tests sont marqués skipped, pas échoués.
import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import Database from 'better-sqlite3';
import { MIGRATIONS, type MigrationDb } from '../services/db/migrations';
import type { DbSession } from '../services/db/session';
import { __setDbForTests } from '../services/db/session';
import { api, setAuthToken } from '../api/client';
import { enqueuePendingOperation, getPendingOperations } from '../services/db';
import { pushPendingOps, refreshPermissions, resetPermissionCachedAtForTests } from '../features/syncOutbox';
const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
type Harness = MigrationDb & DbSession;
let h: Harness;
function createHarness(): Harness {
const sqlite = new Database(':memory:');
const harness: Harness = {
execAsync: async (sql: string) => {
sqlite.exec(sql);
},
runAsync: async (sql: string, ...params: unknown[]) => {
sqlite.prepare(sql).run(...params);
},
getFirstAsync: async (sql: string, ...params: unknown[]) =>
(sqlite.prepare(sql).get(...params) ?? null) as never,
getAllAsync: async (sql: string, ...params: unknown[]) =>
sqlite.prepare(sql).all(...params) as never,
withExclusiveTransactionAsync: async (task: (txn: Harness) => Promise<void>) => {
sqlite.exec('BEGIN');
try {
await task(harness);
sqlite.exec('COMMIT');
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
return harness;
}
let deviceId: string;
function newDeviceId(): string {
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
function isBackendUp(): boolean {
return !!process.env.CI_E2E_REQUIRE_BACKEND;
}
beforeEach(async () => {
h = createHarness();
for (const migration of MIGRATIONS) {
await migration.up(h);
}
__setDbForTests(h);
resetPermissionCachedAtForTests();
});
afterEach(() => {
__setDbForTests(null);
setAuthToken(null);
});
test('bout en bout : register → push outbox → snapshot → relecture serveur', async (t) => {
try {
await api.health();
} catch (error) {
if (!isBackendUp()) {
t.skip(`backend injoignable sur ${BASE_URL} — lancer serveur + postgres`);
return;
}
throw error;
}
deviceId = newDeviceId();
const reg = await api.registerDevice(deviceId);
setAuthToken(reg.data.token);
// 1. Enqueue un CREATE_RESOURCE puis push
const name = `e2e-${deviceId.slice(0, 8)}`;
const resourceId = newDeviceId();
const opId = await enqueuePendingOperation({
resourceId,
resourceType: 'folder',
refType: 'resource',
refId: null,
operation: 'create_resource',
payload: { name },
});
const push = await pushPendingOps();
assert.deepEqual(push, { pushed: 1, retried: 0 });
const local = await getPendingOperations();
const done = local.find((op) => op.id === opId);
assert.equal(done?.status, 'completed');
assert.equal(done?.attempts, 0);
// 2. Relecture côté serveur : le dossier racine créé est visible
const folders = await api.listFolders();
assert.ok(folders.data.some((f) => f.name === name), 'dossier absent côté serveur');
// 3. Snapshot des permissions → persisté localement
const snapshotCount = await refreshPermissions();
assert.ok(snapshotCount < 10_000, `snapshot trop volumineux (${snapshotCount})`);
});