import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { ConflictError, IdempotencyConflictError, NotFoundError, PermissionDeniedError, type FileEntry, } from '../../src/index.js'; import { setupFileRig, type FileTestRig } from './helpers/rig.js'; describe('Standard ops — list/stat/mkdir/delete/move E2E', () => { let rig: FileTestRig; // Simple in-memory backing store on Bob. const tree = new Map(); beforeAll(async () => { tree.clear(); tree.set('/', { name: '', kind: 'dir', size: 0, mtime: 0, metadata: {} }); tree.set('/foo', { name: 'foo', kind: 'dir', size: 0, mtime: 100, metadata: {} }); tree.set('/foo/bar.txt', { name: 'bar.txt', kind: 'file', size: 12, mtime: 200, contentType: 'text/plain', metadata: {}, }); tree.set('/foo/baz.txt', { name: 'baz.txt', kind: 'file', size: 5, mtime: 300, metadata: {}, }); rig = await setupFileRig({ list: async (ctx) => { const dir = ctx.path; const entries: FileEntry[] = []; for (const [path, entry] of tree) { if (path === dir) continue; if (!path.startsWith(dir === '/' ? '/' : dir + '/')) continue; const rest = path.slice(dir === '/' ? 1 : dir.length + 1); if (rest.includes('/')) continue; entries.push(entry); } return { entries, hasMore: false }; }, stat: async (ctx) => { const e = tree.get(ctx.path); if (e === undefined) throw new NotFoundError(`${ctx.path} not found`); return e; }, mkdir: async (ctx) => { if (tree.has(ctx.path)) throw new ConflictError(`${ctx.path} exists`); const name = ctx.path.split('/').filter(Boolean).pop() ?? ''; const entry: FileEntry = { name, kind: 'dir', size: 0, mtime: Date.now(), metadata: {}, }; tree.set(ctx.path, entry); return { entry }; }, delete: async (ctx) => { if (!tree.has(ctx.path)) throw new NotFoundError(ctx.path); let count = 0; if (ctx.args.recursive) { for (const path of [...tree.keys()]) { if (path === ctx.path || path.startsWith(ctx.path + '/')) { tree.delete(path); count++; } } } else { tree.delete(ctx.path); count = 1; } return { deletedCount: count }; }, move: async (ctx) => { const src = ctx.args.src; const dst = ctx.args.dst; const e = tree.get(src); if (e === undefined) throw new NotFoundError(src); if (tree.has(dst) && !ctx.args.overwrite) { throw new ConflictError(`${dst} exists`); } const newName = dst.split('/').filter(Boolean).pop() ?? e.name; tree.delete(src); tree.set(dst, { ...e, name: newName }); return { entry: tree.get(dst)! }; }, }); }); afterAll(async () => { await rig.teardown(); }); test('list /', async () => { const page = await rig.fs.list('/'); expect(page.hasMore).toBe(false); const names = page.entries.map((e) => e.name).sort(); expect(names).toEqual(['foo']); }); test('list /foo', async () => { const page = await rig.fs.list('/foo'); const names = page.entries.map((e) => e.name).sort(); expect(names).toEqual(['bar.txt', 'baz.txt']); }); test('stat existing file', async () => { const e = await rig.fs.stat('/foo/bar.txt'); expect(e.size).toBe(12); expect(e.contentType).toBe('text/plain'); }); test('stat missing → NotFoundError', async () => { let caught: unknown = null; try { await rig.fs.stat('/no/such/file'); } catch (err) { caught = err; } expect(caught instanceof NotFoundError).toBe(true); expect((caught as NotFoundError).payload.code).toBe('NOT_FOUND'); }); test('mkdir creates a new directory', async () => { const result = await rig.fs.mkdir('/created'); expect(result.entry.kind).toBe('dir'); expect(tree.has('/created')).toBe(true); }); test('mkdir on existing path → ConflictError', async () => { await expect(rig.fs.mkdir('/foo')).rejects.toBeInstanceOf(ConflictError); }); test('delete file', async () => { tree.set('/temp.txt', { name: 'temp.txt', kind: 'file', size: 0, mtime: 0, metadata: {} }); const r = await rig.fs.delete('/temp.txt'); expect(r.deletedCount).toBe(1); expect(tree.has('/temp.txt')).toBe(false); }); test('delete missing → NotFoundError', async () => { await expect(rig.fs.delete('/doesnt-exist')).rejects.toBeInstanceOf(NotFoundError); }); test('move file', async () => { tree.set('/x.txt', { name: 'x.txt', kind: 'file', size: 0, mtime: 0, metadata: {} }); const r = await rig.fs.move('/x.txt', '/y.txt'); expect(r.entry.name).toBe('y.txt'); expect(tree.has('/x.txt')).toBe(false); expect(tree.has('/y.txt')).toBe(true); }); test('move overwrite=false → ConflictError on collision', async () => { tree.set('/a.txt', { name: 'a.txt', kind: 'file', size: 0, mtime: 0, metadata: {} }); tree.set('/b.txt', { name: 'b.txt', kind: 'file', size: 0, mtime: 0, metadata: {} }); await expect(rig.fs.move('/a.txt', '/b.txt')).rejects.toBeInstanceOf(ConflictError); }); test('idempotent retry: same key + same args → cached response', async () => { tree.set('/idem.txt', { name: 'idem.txt', kind: 'file', size: 0, mtime: 0, metadata: {} }); const key = 'IdemKey1AaBbCcDdEeFfGg'; // 22 chars const r1 = await rig.fs.delete('/idem.txt', { idempotencyKey: key }); expect(r1.deletedCount).toBe(1); // 2nd call with same key → same result without throwing NotFound const r2 = await rig.fs.delete('/idem.txt', { idempotencyKey: key }); expect(r2.deletedCount).toBe(1); }); test('idempotency conflict: same key + different args', async () => { tree.set('/c1.txt', { name: 'c1.txt', kind: 'file', size: 0, mtime: 0, metadata: {} }); tree.set('/c2.txt', { name: 'c2.txt', kind: 'file', size: 0, mtime: 0, metadata: {} }); const key = 'ConflictKeyAaBbCcDdEeF'; // 22 chars await rig.fs.delete('/c1.txt', { idempotencyKey: key }); await expect( rig.fs.delete('/c2.txt', { idempotencyKey: key }), ).rejects.toBeInstanceOf(IdempotencyConflictError); }); test('path validation: traversal rejected by server', async () => { await expect(rig.fs.list('/foo')).resolves.toBeDefined(); // Client-side schema rejects traversal too — bypass via direct bad path: await expect(rig.fs.stat('/foo/../etc')).rejects.toThrow(); }); // We don't test PermissionDenied here (no beforeOp gate configured), // but the type is referenced to ensure it's exported properly. test('PermissionDeniedError is exported', () => { expect(PermissionDeniedError).toBeDefined(); }); });