Files
Kazier/mobile/tests/syncDevice.test.ts
T
2026-09-11 10:49:12 +02:00

276 lines
8.4 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 { __setWithTransactionForTests } from '../services/db/client';
import { __setSafWalkForTests } from '../services/safWalk';
import type { DirectoryEntry, Folder } from '../services/safDirectory.types';
import { syncRoot, syncDevice } from '../features/syncDevice';
import { saveFolder, getFolders, getFolderFolders, getFiles } from '../services/db';
type Harness = MigrationDb & DbSession;
let h: Harness;
let restoreFns: Array<() => void> = [];
function dir(uri: string): Folder {
return { uri, name: uri.split('/').pop() ?? uri, isDirectory: true, exists: true };
}
function file(uri: string): DirectoryEntry {
return {
uri,
name: uri.split('/').pop() ?? uri,
isDirectory: false,
extension: '',
exists: true,
size: 1,
type: 'text/plain',
lastModified: 0,
};
}
function info(uri: string): { uri: string; name: string; exists: boolean } {
return { uri, name: uri.split('/').pop() ?? uri, exists: true };
}
const tree: Record<string, DirectoryEntry[]> = {
'/root': [dir('/root/a'), dir('/root/b'), file('/root/f1.txt')],
'/root/a': [dir('/root/a/x'), file('/root/a/f2.txt')],
'/root/a/x': [],
'/root/b': [file('/root/b/f3.txt')],
'/rootA': [dir('/rootA/sub')],
'/rootA/sub': [],
'/rootB': [file('/rootB/fB.txt')],
};
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 fakeSaf(): () => void {
return __setSafWalkForTests({
list: (uri) => tree[uri] ?? [],
info,
});
}
async function waitUntil(condition: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!condition()) {
if (Date.now() - start >= timeoutMs) throw new Error('waitUntil: timeout');
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
beforeEach(async () => {
h = createHarness();
for (const migration of MIGRATIONS) {
await migration.up(h);
}
__setDbForTests(h);
__setWithTransactionForTests(async (work) => work({} as never));
restoreFns.push(fakeSaf());
});
afterEach(() => {
__setDbForTests(null);
__setWithTransactionForTests(null);
while (restoreFns.length) restoreFns.pop()?.();
});
test('walk complet : comptes folders/files/missing, parent_resource_id cohérent, exists=1', async () => {
const root = await saveFolder({ uri: '/root', name: 'Root' });
const result = await syncRoot(root.resource_id);
assert.deepEqual(result, { rootUri: '/root', folders: 4, files: 3, missing: 0 });
const stored = await getFiles();
assert.equal(stored.length, 3);
for (const f of stored) {
assert.equal(f.exists, true);
}
const subs = await getFolderFolders(root.resource_id);
assert.equal(subs.length, 2);
const a = subs.find((f) => f.name === 'a')!;
assert.equal(a.parent_resource_id, root.resource_id);
assert.equal(a.exists, true);
const deep = await getFolderFolders(a.resource_id);
assert.equal(deep.length, 1);
assert.equal(deep[0].name, 'x');
assert.equal(deep[0].parent_resource_id, a.resource_id);
assert.equal(deep[0].exists, true);
});
test('single-flight : un second walk nest pas lancé tant que le premier est en cours', async () => {
const rootA = await saveFolder({ uri: '/rootA', name: 'RootA' });
const rootB = await saveFolder({ uri: '/rootB', name: 'RootB' });
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let enteredYield = false;
let listingsB = 0;
restoreFns.push(
__setSafWalkForTests({
list: (uri) => {
if (uri.startsWith('/rootA') && !enteredYield) {
// Prolonge le walk de A pour dépasser le budget de 16ms et atteindre
// son premier `yield` (qui bloque sur la gate) — sinon A se termine
// trop vite et la sérialisation ne peut pas être observée.
const until = Date.now() + 20;
while (Date.now() < until) {}
}
if (uri.startsWith('/rootB')) listingsB++;
return tree[uri] ?? [];
},
yield: async () => {
enteredYield = true;
await gate;
},
info,
}),
);
const pA = syncRoot(rootA.resource_id);
await waitUntil(() => enteredYield);
const pB = syncRoot(rootB.resource_id);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(listingsB, 0, 'pB doit attendre la fin de pA (sérialisation des walks)');
release();
const [rA, rB] = await Promise.all([pA, pB]);
assert.equal(rA.folders, 2);
assert.equal(rB.folders, 1);
assert.ok(listingsB > 0, 'pB doit avoir walké après la libération');
});
test('toute la phase de listing SAF précède la moindre écriture DB', async () => {
const root = await saveFolder({ uri: '/root', name: 'Root' });
let txStarted = false;
__setWithTransactionForTests(async (work) => {
txStarted = true;
return work({} as never);
});
let listCount = 0;
restoreFns.push(
__setSafWalkForTests({
list: (uri) => {
assert.equal(txStarted, false, `écriture DB pendant le listing SAF (liste ${uri})`);
listCount++;
return tree[uri] ?? [];
},
info,
}),
);
const result = await syncRoot(root.resource_id);
assert.ok(listCount >= 4, `listing complet attendu, obtenu ${listCount}`);
assert.equal(result.folders, 4);
assert.equal(result.files, 3);
});
test('walk répété : idempotent, pas de doublon ni SQLITE_CONSTRAINT', async () => {
const root = await saveFolder({ uri: '/root', name: 'Root' });
await syncRoot(root.resource_id);
const foldersBefore = new Map((await getFolders()).map((f) => [f.uri, f.resource_id]));
const filesUriBefore = new Set((await getFiles()).map((f) => f.uri));
await syncRoot(root.resource_id);
const foldersAfter = await getFolders();
assert.equal(foldersAfter.length, foldersBefore.size);
for (const f of foldersAfter) {
assert.equal(foldersBefore.get(f.uri), f.resource_id, `resource_id stable pour ${f.uri}`);
}
const filesAfter = await getFiles();
assert.equal(filesAfter.length, filesUriBefore.size);
for (const f of filesAfter) {
assert.ok(filesUriBefore.has(f.uri));
assert.equal(f.exists, true);
}
});
test('réconciliation : un fichier retiré du SAF passe à exists=0 et compte en missing', async () => {
const root = await saveFolder({ uri: '/root', name: 'Root' });
await syncRoot(root.resource_id);
assert.equal((await getFiles()).length, 3);
restoreFns.push(
__setSafWalkForTests({
list: (uri) =>
uri === '/root'
? (tree['/root'] ?? []).filter((e) => e.uri !== '/root/f1.txt')
: (tree[uri] ?? []),
info,
}),
);
const result = await syncRoot(root.resource_id);
assert.equal(result.missing, 1);
const f1 = (await getFiles()).find((f) => f.uri === '/root/f1.txt')!;
assert.equal(f1.exists, false);
const remaining = (await getFiles()).filter((f) => f.exists);
assert.equal(remaining.length, 2);
});
test('syncDevice marche toutes les roots présentes', async () => {
const rootA = await saveFolder({ uri: '/rootA', name: 'RootA' });
await saveFolder({ uri: '/rootB', name: 'RootB' });
assert.equal(rootA.parent_resource_id, null);
const results = await syncDevice();
assert.equal(results.length, 2);
const byRoot = new Map(results.map((r) => [r.rootUri, r]));
assert.deepEqual(byRoot.get('/rootA'), {
rootUri: '/rootA',
folders: 2,
files: 0,
missing: 0,
});
assert.deepEqual(byRoot.get('/rootB'), {
rootUri: '/rootB',
folders: 1,
files: 1,
missing: 0,
});
});