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)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// 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})`);
|
||||
});
|
||||
Reference in New Issue
Block a user