import assert from 'node:assert/strict'; import test from 'node:test'; import { RecursiveDeletePageLimitError, runRecursiveDelete } from '../src/workflows/recursive-delete.ts'; import { browserRecursiveDeletePersistence } from '../src/workflows/recursive-delete-persistence.ts'; const session = { user_id: 'user-1', display: null, csrf: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }; test('recursive delete continues from the first page through concurrent additions', async () => { const persistence = memoryPersistence(); const plans = [ { paths: ['tree/a'], continuation: 'page-2' }, { paths: ['tree/b'], continuation: null }, { paths: ['tree/new'], continuation: null }, { paths: [], continuation: null } ]; const continuations = []; const batches = []; const client = recursiveClient({ plan(request) { continuations.push(request.continuation); return plans.shift(); }, batch(items) { batches.push(items.map((item) => item.path)); return succeeded(items); } }); let operation = 0; const result = await runRecursiveDelete( client, persistence, session, 'personal', 'tree', () => `operation-${++operation}` ); assert.deepEqual(result, { deleted: 3, pages: 3 }); assert.deepEqual(continuations, [undefined, 'page-2', undefined, undefined]); assert.deepEqual(batches, [['tree/a'], ['tree/b'], ['tree/new']]); assert.equal(persistence.read(), null); }); test('recursive delete reload preserves pending IDs and restarts terminal IDs', async () => { const storage = new MemoryStorage(); const firstAdapter = browserRecursiveDeletePersistence(storage); firstAdapter.save('shared', 'tree', [ { path: 'tree/pending', operation_id: 'pending-id' }, { path: 'tree/failed', operation_id: 'failed-id' } ]); const reloaded = browserRecursiveDeletePersistence(storage); const client = recursiveClient({ plan() { throw new Error('saved batch should replay before planning'); }, batch(items) { return [ { path: items[0].path, operation_id: items[0].operation_id, result: 'pending', error: null }, { path: items[1].path, operation_id: items[1].operation_id, result: 'failed', error: null } ]; } }); await assert.rejects( runRecursiveDelete( client, reloaded, session, 'shared', 'tree', () => 'replacement-id' ), /2 objects remain/ ); assert.deepEqual(reloaded.read()?.items, [ { path: 'tree/pending', operation_id: 'pending-id' }, { path: 'tree/failed', operation_id: 'replacement-id' } ]); }); test('recursive delete enforces the page bound and retains resumable work', async () => { const persistence = memoryPersistence(); const client = recursiveClient({ plan() { return { paths: ['tree/item'], continuation: null }; }, batch(items) { return succeeded(items); } }); let operation = 0; await assert.rejects( runRecursiveDelete( client, persistence, session, 'personal', 'tree', () => `operation-${++operation}`, 100 ), (error) => error instanceof RecursiveDeletePageLimitError && error.pages === 100 ); assert.equal(operation, 100); }); test('invalid persisted state is removed on reload', () => { const storage = new MemoryStorage(); storage.setItem('nuthatch-recursive-delete', '{"items":[null]}'); const persistence = browserRecursiveDeletePersistence(storage); assert.equal(persistence.read(), null); assert.equal(storage.length, 0); }); function recursiveClient(behavior) { return { async deletePlan(request) { const plan = behavior.plan(request); return { ok: true, value: plan }; }, async deleteBatch({ body }) { return { ok: true, value: { results: behavior.batch(body.items) } }; } }; } function succeeded(items) { return items.map((item) => ({ path: item.path, operation_id: item.operation_id, result: 'succeeded', error: null })); } function memoryPersistence() { return browserRecursiveDeletePersistence(new MemoryStorage()); } class MemoryStorage { values = new Map(); get length() { return this.values.size; } clear() { this.values.clear(); } getItem(key) { return this.values.get(key) ?? null; } key(index) { return [...this.values.keys()][index] ?? null; } removeItem(key) { this.values.delete(key); } setItem(key, value) { this.values.set(key, value); } }