- 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)
121 lines
3.8 KiB
TypeScript
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})`);
|
|
}); |