feat(core-storage): add PouchEnvelope tests and LocalStorageService implementation
- Introduced tests for PouchEnvelope to validate envelope-aware CRUD operations using an in-memory PouchDB. - Implemented LocalStorageService with encryption support for sensitive keys, including tests for setItem, getItem, removeItem, and clear methods. - Defined a generic storage interface (IStorageService) to enforce type-safe serialization/deserialization across storage implementations.
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
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<T extends object>(data: T) {
|
||||
return raw.put(data as PouchDB.Core.Document<T>);
|
||||
},
|
||||
|
||||
async createBulk<T extends object>(
|
||||
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<T>[],
|
||||
);
|
||||
results.push(...response);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
|
||||
async update<T extends object>(id: string, data: Partial<T>) {
|
||||
const existing = await raw.get(id);
|
||||
const merged = { ...existing, ...data };
|
||||
return raw.put(merged);
|
||||
},
|
||||
|
||||
async upsert<T extends object>(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<T>);
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const doc = await raw.get(id);
|
||||
return raw.remove(doc);
|
||||
},
|
||||
|
||||
// ─── Reads ────────────────────────────────────────────────
|
||||
|
||||
async getOne<T>(id: string) {
|
||||
return raw.get<T>(id) as Promise<
|
||||
T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta
|
||||
>;
|
||||
},
|
||||
|
||||
async getAll<T>() {
|
||||
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<T>(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<T extends object>(options: PouchDB.Find.FindRequest<T>) {
|
||||
const result = await raw.find(
|
||||
options as PouchDB.Find.FindRequest<object>,
|
||||
);
|
||||
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<object> | 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<typeof createTestDB>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user