Files
Kazier/mobile/tests/syncOutbox.test.ts
T
m 0536fca6c3 feat(mobile): identité user-first — login/password, SecureStore, scoping par compte
- api: DeviceRegistration device-only, login/changePassword/resolveUser, interception 401 (purge) exonérée sur le login ; type User aligné sur le fil (is_admin)
- SecureStore (expo-secure-store v57) : token + profil, jamais en SQLite ; miroir active_user_id
- AuthContext : statut loading/signedOut/signedIn, bootstrap register→restore, signIn/signOut, garde-fou switch de compte
- app/login.tsx + gate de routes dans _layout (Redirect signedOut) ; i18n fr/en
- DB v5 : user_id sur pending_operations + resource_permissions (UNIQUE par user), repos scopés (user_id IS ? OR IS NULL)
- syncOutbox : delta permissions par compte (Map), outbox poussée du compte actif uniquement
- tests: register/login/401/resolve/changePassword, scoping outbox+permissions, migrations v5 ; e2e live revert register→login admin
2026-09-10 21:34:34 +02:00

402 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
getActiveUserId,
getPendingOperations,
getResourcePermission,
listQueuedOperations,
markPendingOperation,
scheduleRetries,
setActiveUserId,
MAX_PENDING_ATTEMPTS,
} from '../services/db';
import {
pushPendingOps,
refreshPermissions,
resetPermissionCachedAtForTests,
} from '../features/syncOutbox';
import type { PendingOperationType } from '../services/db/types';
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;
}
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function ok(data: unknown): Response {
return jsonResponse(200, { data });
}
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) => {
const initSafe = init ?? {};
calls.push({ url: String(input), init: initSafe });
return handler(String(input), initSafe);
};
}
function pathOf(url: string): string {
const u = new URL(url);
return u.pathname + u.search;
}
beforeEach(async () => {
h = createHarness();
for (const migration of MIGRATIONS) {
await migration.up(h);
}
__setDbForTests(h);
calls = [];
setAuthToken('v4.local.test-token');
resetPermissionCachedAtForTests();
stubFetch(() => ok({ applied: 0, failed: null }));
});
afterEach(() => {
__setDbForTests(null);
globalThis.fetch = originalFetch;
});
async function enqueue(op: {
operation: PendingOperationType;
resourceId?: string;
payload?: Record<string, unknown>;
}) {
return enqueuePendingOperation({
resourceId: op.resourceId ?? 'a'.repeat(32),
resourceType: 'folder',
refType: 'resource',
refId: 7,
operation: op.operation,
payload: op.payload ?? { name: 'Dossier' },
});
}
test('sans token : aucun push, aucune requête réseau', async () => {
setAuthToken(null);
await enqueue({ operation: 'create_resource' });
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 0, retried: 0 });
assert.equal(calls.length, 0);
});
test('queue vide : aucune requête réseau', async () => {
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 0, retried: 0 });
assert.equal(calls.length, 0);
});
test('succès complet : toutes les ops passent à completed, body conforme', async () => {
const id1 = await enqueue({ operation: 'create_resource', payload: { name: 'A' } });
const id2 = await enqueue({ operation: 'create_resource', payload: { name: 'B' } });
stubFetch(() => ok({ applied: 2, failed: null }));
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 2, retried: 0 });
assert.equal(pathOf(calls[0].url), '/api/v1/sync/ops');
assert.equal(calls[0].init.method, 'POST');
const body = JSON.parse(String(calls[0].init.body));
assert.equal(body.operations.length, 2);
assert.equal(body.operations[0].operation_id, id1);
assert.equal(body.operations[0].resource_type, 'folder');
assert.deepEqual(body.operations[0].payload, { name: 'A' });
assert.equal(body.operations[1].operation_id, id2);
const ops = await getPendingOperations();
const byId = new Map(ops.map((op) => [op.id, op]));
assert.equal(byId.get(id1)?.status, 'completed');
assert.equal(byId.get(id2)?.status, 'completed');
assert.equal(byId.get(id1)?.attempts, 0);
});
test('succès partiel : reprise exacte à lindex applied (les suivantes intactes)', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
const id2 = await enqueue({ operation: 'create_resource' });
const id3 = await enqueue({ operation: 'create_resource' });
stubFetch(() =>
ok({
applied: 1,
failed: { operation_id: id2, code: 'NAME_CONFLICT', message: 'exists déjà' },
}),
);
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 1, retried: 0 });
const ops = await getPendingOperations();
const byId = new Map(ops.map((op) => [op.id, op]));
assert.equal(byId.get(id1)?.status, 'completed');
assert.equal(byId.get(id2)?.status, 'pending');
assert.equal(byId.get(id2)?.attempts, 1); // backoff programmé, par dead-letter
assert.ok((byId.get(id2)?.nextRetryAt ?? 0) > 0);
assert.match(byId.get(id2)?.error ?? '', /NAME_CONFLICT/);
assert.equal(byId.get(id3)?.status, 'pending');
assert.equal(byId.get(id3)?.attempts, 0); // jamais soumise → intacte
});
test('applied = 0 + échec : rien commité, pas de dead-letter prématurée', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
stubFetch(() =>
ok({ applied: 0, failed: { operation_id: id1, code: 'INVALID_REQUEST', message: 'payload invalide' } }),
);
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 0, retried: 0 });
const [op] = await getPendingOperations();
assert.equal(op.status, 'pending');
assert.equal(op.attempts, 1); // < MAX_PENDING_ATTEMPTS, le cycle recommence
assert.ok((op.nextRetryAt ?? 0) > 0);
});
test('échec transitoire (fetch rejette) : retrié sans incrémenter attempts', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
const id2 = await enqueue({ operation: 'create_resource' });
stubFetch(() => {
throw new Error('network unreachable');
});
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 0, retried: 2 });
const ops = await getPendingOperations();
for (const op of ops) {
assert.equal(op.status, 'pending');
assert.equal(op.attempts, 0);
assert.ok((op.nextRetryAt ?? 0) > Date.now());
}
});
test('échec transitoire 5xx : idem (HTTP_500 → retry, pas de dead-letter)', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
stubFetch(() => jsonResponse(500, { error: { code: 'INTERNAL', message: 'boom' } }));
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 0, retried: 1 });
const [op] = await getPendingOperations();
assert.equal(op.status, 'pending');
assert.equal(op.attempts, 0);
});
test('refus terminal répété : dead-letter après MAX_PENDING_ATTEMPTS', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
stubFetch(() =>
ok({ applied: 0, failed: { operation_id: id1, code: 'NAME_CONFLICT', message: 'dup' } }),
);
for (let i = 0; i < MAX_PENDING_ATTEMPTS; i++) {
await pushPendingOps();
// le backoff repousse next_retry_at : on force le reset pour re-soumettre
await h.runAsync('UPDATE pending_operations SET next_retry_at = NULL');
}
const [op] = await getPendingOperations();
assert.equal(op.status, 'failed');
assert.equal(op.attempts, MAX_PENDING_ATTEMPTS);
assert.equal(op.nextRetryAt, null); // plus jamais rejouée
});
test('listQueuedOperations honore le tri FIFO et le LIMIT', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
const id2 = await enqueue({ operation: 'create_resource' });
const id3 = await enqueue({ operation: 'create_resource' });
const first = await listQueuedOperations(2);
assert.deepEqual(first.map((op) => op.id), [id1, id2]);
const later = Date.now() + 60_000;
await scheduleRetries([id2], later);
const due = await listQueuedOperations(10);
assert.deepEqual(due.map((op) => op.id), [id1, id3]); // id2 pas encore due
});
test('scheduleRetries ne change pas attempts (différent de markPendingOperation failed)', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
await scheduleRetries([id1], Date.now() + 1);
const [op] = await getPendingOperations();
assert.equal(op.attempts, 0);
assert.ok((op.nextRetryAt ?? 0) > 0);
});
test('refreshPermissions : delta après = max cachedAt du snapshot', async () => {
const perms = [
{
resource_id: 'a'.repeat(32),
resourceType: 'folder',
effectiveAccess: 'owner',
inherit: true,
ownerId: 'u1',
sharedById: null,
expiresAt: null,
cachedAt: 1000,
updatedAt: 1000,
},
{
resource_id: 'b'.repeat(32),
resourceType: 'file',
effectiveAccess: 'viewer',
inherit: false,
ownerId: 'u2',
sharedById: 'u1',
expiresAt: 5,
cachedAt: 2000,
updatedAt: 2000,
},
];
stubFetch((url) => (new URL(url).searchParams.get('after') === null ? ok(perms) : ok([])));
const n1 = await refreshPermissions();
assert.equal(n1, 2);
assert.equal(pathOf(calls[0].url), '/api/v1/sync/permissions');
assert.equal((await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess, 'owner');
assert.equal((await getResourcePermission('b'.repeat(32), 'file'))?.expiresAt, 5);
// 2e tick (snapshot vide) → after = max cachedAt du 1er
const n2 = await refreshPermissions();
assert.equal(n2, 0);
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions?after=2000');
});
test('refreshPermissions garde la référence dans le temps pour after', async () => {
const first = {
resource_id: 'c'.repeat(32),
resourceType: 'folder',
effectiveAccess: 'editor',
inherit: true,
ownerId: 'u1',
sharedById: null,
expiresAt: null,
cachedAt: 42,
updatedAt: 42,
};
const second = {
resource_id: 'd'.repeat(32),
resourceType: 'folder',
effectiveAccess: 'viewer',
inherit: false,
ownerId: 'u3',
sharedById: 'u1',
expiresAt: null,
cachedAt: 84,
updatedAt: 84,
};
stubFetch((url) => {
const after = new URL(url).searchParams.get('after');
return ok(after === null ? [first] : [second]);
});
await refreshPermissions();
await refreshPermissions();
assert.equal((await getResourcePermission('d'.repeat(32), 'folder'))?.effectiveAccess, 'viewer');
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions?after=42');
});
test('outbox scopée par compte : seul le compte actif voit/pousse ses ops', async () => {
await setActiveUserId('u1');
const idA = await enqueue({ operation: 'create_resource', payload: { name: 'A' } });
await setActiveUserId('u2');
const idB = await enqueue({ operation: 'create_resource', payload: { name: 'B' } });
assert.equal(await getActiveUserId(), 'u2');
const visibleAsU2 = (await getPendingOperations()).map((op) => op.id);
assert.deepEqual(visibleAsU2, [idB], 'u2 ne voit que ses opérations');
await setActiveUserId('u1');
assert.deepEqual(
(await getPendingOperations()).map((op) => op.id),
[idA],
'u1 ne voit que ses opérations',
);
stubFetch(() => ok({ applied: 1, failed: null }));
await setActiveUserId('u2');
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 1, retried: 0 });
const body = JSON.parse(String(calls[0].init.body));
assert.equal(body.operations.length, 1, 'u2 ne pousse pas les opérations de u1');
assert.equal(body.operations[0].operation_id, idB);
await setActiveUserId('u1');
const leftover = await getPendingOperations();
assert.deepEqual(leftover.map((op) => op.id), [idA], 'lop u1 reste intacte et non-poussée');
});
test('permissions scopées par compte : snapshot et miroir par compte', async () => {
const permA = {
resource_id: 'a'.repeat(32),
resourceType: 'folder',
effectiveAccess: 'owner',
inherit: true,
ownerId: 'u1',
sharedById: null,
expiresAt: null,
cachedAt: 1000,
updatedAt: 1000,
};
await setActiveUserId('u1');
stubFetch((url) => (new URL(url).searchParams.get('after') === null ? ok([permA]) : ok([])));
await refreshPermissions();
assert.equal(
(await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess,
'owner',
'u1 voit la permission de son snapshot',
);
// u2 n'a pas encore de snapshot : pas d'`after` (repart du début) et la
// permission de u1 n'est pas visible tant que son propre snapshot n'est pas
// arrivé.
await setActiveUserId('u2');
assert.equal(await getResourcePermission('a'.repeat(32), 'folder'), null);
await refreshPermissions();
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions', 'u2 repart sans after');
assert.equal(
(await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess,
'owner',
'u2 voit sa propre copie après son snapshot',
);
});