import { TypeHandler } from '../src/index.js'; import PouchDB from 'pouchdb'; import PouchDBFind from 'pouchdb-find'; import memory from 'pouchdb-adapter-memory'; const PDB = PouchDB.plugin(PouchDBFind).plugin(memory); class ContactHandler extends TypeHandler { getUniqueID(doc) { return doc.email; } validate(doc) { if (typeof doc.email != 'string') { throw new Error('email property is required'); } else if (doc.email.length <= 2) { throw new Error('email must be more than 2 characters'); } } } class LocationHandler extends TypeHandler { getUniqueID(doc) { return doc.latlon; } } const notDesignDocs = d => !d.id.startsWith('_design'); function compareDataToDoc(dataObj, doc) { Object.keys(dataObj).forEach(k => { expect(doc[k]).toEqual(dataObj[k]); }); } describe('PouchType Handler', () => { const db = new PDB('pouchtype-test', { adapter: 'memory' }); const Contact = new ContactHandler(db, 'contact'); const Location = new LocationHandler(db, 'location'); Contact.index('lastIndex', ['last']); const dataset = [ { first: 'Jane', last: 'Doe', email: 'jd@example.com', type: 'contact', _id: 'contact_jd@example.com' }, { first: 'Bob', last: 'Smith', email: 'bs@example.com', type: 'contact', _id: 'contact_bs@example.com' }, { description: 'Home', latlon: '12345', type: 'location', _id: 'location_12345' }, { first: 'Joe', last: 'Smithers', email: 'js@example.com', type: 'contact', _id: 'contact_js@example.com' } ]; async function flushDb() { const res = await db.allDocs(); await Promise.all(res.rows.filter(notDesignDocs).map(d => db.remove(d.id, d.value.rev))).catch( () => {} ); await db.compact(); } afterEach(flushDb); it('.getOrCreate() gets existing records.', async () => { const doc = { first: 'Jane', last: 'Doe', email: 'jd@example.com' }; await db.bulkDocs(dataset); const existing = await db.get(dataset[0]._id); const instance = await Contact.getOrCreate(doc); expect(instance._id).toEqual(existing._id); expect(instance._rev).toEqual(existing._rev); }); it('.getOrCreate() saves non-existing records and populates _id and _rev.', async () => { const doc = { first: 'Jill', last: 'Doener', email: 'jd2@example.com' }; const expectedId = `${Contact.type}_${doc.email}`; try { await db.get(expectedId); fail('db.get() should throw when passed an id of a removed document.'); return; } catch (e) { expect(e.status).toBe(404); } const instance = await Contact.getOrCreate(doc); expect(instance._id).toEqual(expectedId); expect(instance._rev).toBeTruthy(); compareDataToDoc(doc, instance); const savedDoc = await db.get(expectedId); expect(savedDoc._id).toEqual(expectedId); expect(savedDoc._rev).toBeTruthy(); compareDataToDoc(doc, savedDoc); }); it('.get() returns a document when it exists.', async () => { await db.bulkDocs(dataset); const instance = await Contact.get(dataset[1]._id); compareDataToDoc(dataset[1], instance); }); it(".get() returns null when it doesn't exist or has been removed.", async () => { await db.bulkDocs(dataset); const empty = await Contact.get('does_not_exist'); expect(empty).toBeNull(); const doc = await db.get(dataset[1]._id); doc._deleted = true; await db.put(doc); const empty2 = await Contact.get('does_not_exist'); expect(empty).toBeNull(); }); it('.get() only returns records for its type.', async () => { await db.bulkDocs(dataset); const instance = await Contact.get(dataset.filter(d => d.type !== Contact.type)[0]._id); expect(instance).toBeNull(); }); it('.filter() returns an array of matching instances.', async () => { await db.bulkDocs(dataset); const res = await Contact.filter({ last: 'Smith' }); expect(res.length).toBe(1); }); it('.filter() only returns records for its type.', async () => { await db.bulkDocs(dataset); const res = await Contact.filter({}); expect(res.length).toBe(3); res.forEach(c => expect(c.type).toBe(Contact.type)); }); it('.isType(instance) identifies instances of the type.', async () => { const instance = await Contact.getOrCreate({ first: 'Bob', last: 'Smith', email: 'bs@example.com' }); expect(Contact.isType(instance)).toBe(true); }); it('.isType(doc) detects documents of the type.', async () => { const data = await Contact.getOrCreate({ first: 'Bob', last: 'Smith', email: 'bs@example.com' }); const doc = await db.get(data._id); expect(Contact.isType(doc)).toBe(true); }); it('.remove() sets the ._deleted property and documents are no longer gettable.', async () => { const id = dataset[1]._id; await db.bulkDocs(dataset); const doc = await Contact.get(id); await Contact.remove(doc); expect(doc._deleted).toBe(true); try { await db.get(id); fail('db.get() should throw when passed an id of a removed document.'); return; } catch (e) { expect(e.status).toBe(404); } const doc2 = await Contact.get(id); expect(doc2).toBeNull(); }); it('.save() populates ._id and .type properties and writes to the db.', async () => { const doc = { first: 'Bob', last: 'Smith', email: 'bs@example.com' }; await Contact.save(doc); const d = await db.get(doc._id); expect(Contact.isType(d)).toBe(true); expect(d._id).toBeTruthy(); expect(d.type).toEqual(Contact.type); compareDataToDoc(doc, d); }); it('.validate() can interrupt save() by throwing an exception.', async () => { const doc = { first: 'Bob', last: 'Smith', email: 'bs' }; try { await Contact.save(doc); } catch (e) { expect(e.message).toBe('email must be more than 2 characters'); return; } fail('TypeHandler.save() should call validate on save()'); }); it('.update() will deeply apply object properties to a document.', async () => { const data = { first: 'Bob', last: 'Smitherines', email: 'bsmitherines@example.com', addresses: { home: '123 Privet Drive' } }; const doc = await Contact.save(data); await Contact.update(doc, { addresses: { home: '221B Baker Street' } }); const doc2 = await Contact.get(doc._id); expect(doc2.addresses.home).toEqual(doc.addresses.home); }); it('.update() only updates the database when the underlying document changes and save is not precluded.', async () => { const data = { first: 'Bob', last: 'Smitherines', email: 'bsmitherines@example.com' }; const doc = await Contact.save(data); spyOn(Contact, 'save'); await Contact.update(doc, { last: 'Shell', email: 'bshell@example.com' }); expect(Contact.save).toHaveBeenCalledTimes(1); await Contact.update(doc, { last: 'Shell' }); expect(Contact.save).toHaveBeenCalledTimes(1); await Contact.update(doc, { last: 'Tootsie' }, false); expect(Contact.save).toHaveBeenCalledTimes(1); // TODO: Potentially undesirable/non-intuitive behavior here. Update calls save even though the data hasn't truly changed from the last saved state. await Contact.update(doc, { last: 'Shell' }); expect(Contact.save).toHaveBeenCalledTimes(2); }); it('.watch() returns a subscribable LiveArray of matching instances.', done => { let expectedLength = 0; let checkCount = 0; let sub; let livedata; function poll(fn, val) { return new Promise(resolve => { const wrap = () => { if (fn()) { resolve(val); } else { setTimeout(wrap, 5); } }; wrap(); }); } return Contact.watch({ watchTest: { $eq: true } }) .then(_ld => { livedata = _ld; sub = livedata.subscribe(data => { checkCount += 1; expect(data.length).toBe(expectedLength); }); return db.bulkDocs(dataset); }) .then(_ => poll(() => { return livedata().length === 0; }) ) .then(_ => { expectedLength = 1; return db.put({ first: 'Bill', last: 'Smitherts', email: 'bsmithers@example.com', _id: 'contact_bsmithers@example.com', type: 'contact', watchTest: true }); }) .then(_ => poll(() => { return livedata().length === 1; }) ) .then(_ => { return db.put({ first: 'Bart', last: 'Smitty', email: 'bsmitty@example.com', type: 'contact', _id: 'contact_bsmitty@example.com' }); }) .then(res => { return poll(() => { return livedata().length === 1; }, res); }) .then(res => { expectedLength = 2; db.put({ first: 'Bart', last: 'Smitty', email: 'bsmitty@example.com', type: 'contact', _id: 'contact_bsmitty@example.com', _rev: res.rev, watchTest: true }); return poll(() => { return livedata().length === 2; }); }) .then(_ => { expect(livedata().length).toBe(expectedLength); }) .then(done); }); it('.index() creates an index for non-id sorting.', async () => { await db.bulkDocs(dataset); try { const res = await Contact.filter( { last: { $gte: '' } }, { sort: ['last'], index: 'lastIndex' } ); expect(res.length).toEqual(3); expect(res[0].last).toEqual('Doe'); expect(res[1].last).toEqual('Smith'); expect(res[2].last).toEqual('Smithers'); } catch (e) { fail(e); } }); });