fix the unfixeble

This commit is contained in:
m
2026-09-11 10:49:12 +02:00
parent 76ed3aa66b
commit 2d9be3f489
13 changed files with 869 additions and 51 deletions
+276
View File
@@ -0,0 +1,276 @@
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,
});
});