import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import PouchDB from 'pouchdb-core'; import PouchDBAdapterMemory from 'pouchdb-adapter-memory'; import PouchDBFind from 'pouchdb-find'; import PouchDBMapReduce from 'pouchdb-mapreduce'; // Build a minimal PouchDB for testing: core + memory adapter + find PouchDB.plugin(PouchDBAdapterMemory); PouchDB.plugin(PouchDBFind); PouchDB.plugin(PouchDBMapReduce); /** * Since the tests run in Node (not a browser), we cannot use PouchBase * directly because it imports `pouchdb-browser` which requires `self`. * * We create a TestPouchBase helper that mirrors PouchBase's methods * using the memory-backed PouchDB, exercising the same logic paths. */ function createTestDB(name: string) { const raw = new PouchDB(name, { adapter: 'memory' }); return { raw, // ─── CRUD ───────────────────────────────────────────────── async create(data: T) { return raw.put(data as PouchDB.Core.Document); }, async createBulk( dataList: T[], batchSize = 100, ): Promise<(PouchDB.Core.Response | PouchDB.Core.Error)[]> { const results: (PouchDB.Core.Response | PouchDB.Core.Error)[] = []; for (let i = 0; i < dataList.length; i += batchSize) { const batch = dataList.slice(i, i + batchSize); const response = await raw.bulkDocs( batch as PouchDB.Core.Document[], ); results.push(...response); } return results; }, async update(id: string, data: Partial) { const existing = await raw.get(id); const merged = { ...existing, ...data }; return raw.put(merged); }, async upsert(id: string, data: T) { const existing = await raw.get(id).catch(() => null); if (existing) { return raw.put({ ...existing, ...data }); } return raw.put({ ...data, _id: id } as PouchDB.Core.Document); }, async delete(id: string) { const doc = await raw.get(id); return raw.remove(doc); }, // ─── Reads ──────────────────────────────────────────────── async getOne(id: string) { return raw.get(id) as Promise< T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta >; }, async getAll() { const result = await raw.allDocs({ include_docs: true }); return result.rows .filter((row) => !row.id.startsWith('_design/')) .map((row) => row.doc as unknown as T); }, async getSome(ids: string[]) { const result = await raw.allDocs({ keys: ids, include_docs: true }); return result.rows .filter((row): row is any => !('error' in row) && !!(row as any).doc) .map((row: any) => row.doc as T); }, async find(options: PouchDB.Find.FindRequest) { const result = await raw.find( options as PouchDB.Find.FindRequest, ); return result.docs as unknown as T[]; }, // ─── Bulk ───────────────────────────────────────────────── async cleanAllData() { const result = await raw.allDocs(); const deletions = result.rows .filter((row) => !row.id.startsWith('_design/')) .map((row) => ({ _id: row.id, _rev: row.value.rev, _deleted: true as const, })); if (deletions.length > 0) { await raw.bulkDocs(deletions); } }, // ─── Lifecycle ──────────────────────────────────────────── listeners: new Set<() => void>(), changesFeed: null as PouchDB.Core.Changes | null, onChange(callback: () => void): () => void { this.listeners.add(callback); if (!this.changesFeed) { this.changesFeed = raw .changes({ since: 'now', live: true, include_docs: true }) .on('change', () => { this.listeners.forEach((cb) => cb()); }) .on('error', (err) => { console.warn(`[PouchDB Listener Error]`, err); }); } return () => { this.listeners.delete(callback); }; }, async destroy() { if (this.changesFeed) { this.changesFeed.cancel(); this.changesFeed = null; } this.listeners.clear(); await raw.destroy(); }, }; } // ─── Test Suite ─────────────────────────────────────────────────── describe('PouchBase — Core CRUD Operations', () => { let db: ReturnType; beforeEach(() => { db = createTestDB( `test_base_${Date.now()}_${Math.random().toString(36).slice(2)}`, ); }); afterEach(async () => { try { await db.destroy(); } catch { // Already destroyed in some tests } }); // ─── create ─────────────────────────────────────────────────── describe('create', () => { it('should create a document with a given _id', async () => { const res = await db.create({ _id: 'doc-001', name: 'Alice', age: 30 }); expect(res.ok).toBe(true); expect(res.id).toBe('doc-001'); }); it('should throw a conflict if creating with a duplicate _id', async () => { await db.create({ _id: 'dup-001', name: 'First' }); await expect( db.create({ _id: 'dup-001', name: 'Second' }), ).rejects.toThrow(); }); }); // ─── createBulk ─────────────────────────────────────────────── describe('createBulk', () => { it('should create multiple documents in a single batch', async () => { const docs = [ { _id: 'bulk-1', val: 1 }, { _id: 'bulk-2', val: 2 }, { _id: 'bulk-3', val: 3 }, ]; const results = await db.createBulk(docs); expect(results).toHaveLength(3); results.forEach((r: any) => expect(r.ok).toBe(true)); const all = await db.getAll<{ val: number }>(); expect(all).toHaveLength(3); }); it('should handle batching for large datasets', async () => { const docs = Array.from({ length: 250 }, (_, i) => ({ _id: `batch-${i}`, val: i, })); const results = await db.createBulk(docs, 100); expect(results).toHaveLength(250); const all = await db.getAll(); expect(all).toHaveLength(250); }); }); // ─── getOne ─────────────────────────────────────────────────── describe('getOne', () => { it('should retrieve a document by id', async () => { await db.create({ _id: 'fetch-001', product: 'Widget', price: 9.99 }); const doc = await db.getOne<{ product: string; price: number }>( 'fetch-001', ); expect(doc._id).toBe('fetch-001'); expect(doc.product).toBe('Widget'); expect(doc.price).toBe(9.99); }); it('should throw for a non-existent document', async () => { await expect(db.getOne('non-existent')).rejects.toThrow(); }); }); // ─── update ─────────────────────────────────────────────────── describe('update', () => { it('should merge new fields into an existing document', async () => { await db.create({ _id: 'upd-001', name: 'Original', count: 1 }); const res = await db.update('upd-001', { count: 42, extra: 'field' }); expect(res.ok).toBe(true); const updated = await db.getOne<{ name: string; count: number; extra: string; }>('upd-001'); expect(updated.name).toBe('Original'); // untouched expect(updated.count).toBe(42); // updated expect(updated.extra).toBe('field'); // newly added }); it('should throw when updating a non-existent document', async () => { await expect(db.update('ghost', { name: 'nope' })).rejects.toThrow(); }); }); // ─── upsert ─────────────────────────────────────────────────── describe('upsert', () => { it('should create a new document when it does not exist', async () => { const res = await db.upsert('ups-001', { name: 'NewItem', price: 19.99, }); expect(res.ok).toBe(true); const doc = await db.getOne<{ name: string; price: number }>('ups-001'); expect(doc.name).toBe('NewItem'); expect(doc.price).toBe(19.99); }); it('should update an existing document when it already exists', async () => { await db.create({ _id: 'ups-002', name: 'Original', count: 1 }); const res = await db.upsert('ups-002', { name: 'Updated', count: 99, } as any); expect(res.ok).toBe(true); const doc = await db.getOne<{ name: string; count: number }>('ups-002'); expect(doc.name).toBe('Updated'); expect(doc.count).toBe(99); }); }); // ─── delete ─────────────────────────────────────────────────── describe('delete', () => { it('should remove a document by id', async () => { await db.create({ _id: 'del-001', name: 'ToBeDeleted' }); const res = await db.delete('del-001'); expect(res.ok).toBe(true); await expect(db.getOne('del-001')).rejects.toThrow(); }); }); // ─── getAll ─────────────────────────────────────────────────── describe('getAll', () => { it('should return all documents as a flat array', async () => { await db.create({ _id: 'a', val: 1 }); await db.create({ _id: 'b', val: 2 }); await db.create({ _id: 'c', val: 3 }); const all = await db.getAll<{ val: number }>(); expect(all).toHaveLength(3); expect(all.map((d: any) => d.val).sort()).toEqual([1, 2, 3]); }); it('should return empty array for empty database', async () => { const all = await db.getAll(); expect(all).toEqual([]); }); }); // ─── getSome ────────────────────────────────────────────────── describe('getSome', () => { it('should return only the requested documents', async () => { await db.create({ _id: 'x1', v: 10 }); await db.create({ _id: 'x2', v: 20 }); await db.create({ _id: 'x3', v: 30 }); const some = await db.getSome<{ v: number }>(['x1', 'x3']); expect(some).toHaveLength(2); expect(some.map((d: any) => d.v).sort()).toEqual([10, 30]); }); it('should silently skip missing ids', async () => { await db.create({ _id: 'exists', v: 1 }); const some = await db.getSome<{ v: number }>(['exists', 'ghost']); expect(some).toHaveLength(1); expect((some[0] as any).v).toBe(1); }); }); // ─── find (pouchdb-find selectors) ──────────────────────────── describe('find', () => { it('should filter documents using selectors', async () => { await db.create({ _id: 'p1', category: 'electronics', price: 100 }); await db.create({ _id: 'p2', category: 'clothing', price: 50 }); await db.create({ _id: 'p3', category: 'electronics', price: 200 }); const results = await db.find<{ category: string; price: number }>({ selector: { category: 'electronics' }, }); expect(results).toHaveLength(2); expect(results.every((r) => r.category === 'electronics')).toBe(true); }); it('should support $gt comparisons', async () => { await db.create({ _id: 'i1', price: 10 }); await db.create({ _id: 'i2', price: 50 }); await db.create({ _id: 'i3', price: 100 }); const results = await db.find<{ price: number }>({ selector: { price: { $gt: 40 } }, }); expect(results).toHaveLength(2); expect(results.every((r) => r.price > 40)).toBe(true); }); }); // ─── cleanAllData ───────────────────────────────────────────── describe('cleanAllData', () => { it('should remove all documents but keep the database intact', async () => { await db.create({ _id: 'c1', name: 'One' }); await db.create({ _id: 'c2', name: 'Two' }); await db.create({ _id: 'c3', name: 'Three' }); let all = await db.getAll(); expect(all).toHaveLength(3); await db.cleanAllData(); all = await db.getAll(); expect(all).toHaveLength(0); // Database should still be functional after cleaning await db.create({ _id: 'c4', name: 'Four' }); all = await db.getAll(); expect(all).toHaveLength(1); }); }); // ─── onChange ────────────────────────────────────────────────── describe('onChange', () => { it('should fire listener when a document is created', async () => { const listener = vi.fn(); const unsubscribe = db.onChange(listener); await db.create({ _id: 'change-001', name: 'trigger' }); // Changes feed is async — give it a moment await new Promise((resolve) => setTimeout(resolve, 200)); expect(listener).toHaveBeenCalled(); unsubscribe(); }); it('should stop firing after unsubscribe', async () => { const listener = vi.fn(); const unsubscribe = db.onChange(listener); unsubscribe(); await db.create({ _id: 'change-002', name: 'silent' }); await new Promise((resolve) => setTimeout(resolve, 200)); expect(listener).not.toHaveBeenCalled(); }); }); });