From bf9c9d7ba9828e339ba0b14923c76d5e336bc72c Mon Sep 17 00:00:00 2001 From: Lawrence Date: Sat, 21 Sep 2024 08:44:22 +0100 Subject: [PATCH 01/20] test: mock engine should not give values by reference --- test/mocks/engine.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/test/mocks/engine.js b/test/mocks/engine.js index d5d0b03..6c25366 100644 --- a/test/mocks/engine.js +++ b/test/mocks/engine.js @@ -1,4 +1,5 @@ import Engine from '../../src/engine/Engine.js'; +import _ from 'lodash'; import sinon from 'sinon'; export function getTestEngine(models = []) { @@ -8,46 +9,46 @@ export function getTestEngine(models = []) { const _searchIndexCompiled = {}; for (const model of models) { - _models[model.id] = model; + _models[model.id] = _.cloneDeep(model); } class TestEngine extends Engine { } TestEngine.getById = sinon.stub().callsFake(async (id) => { - if (_models[id]) return _models[id]; + if (_models[id]) return _.cloneDeep(_models[id]); throw new Error(`Model ${id} not found.`); }); TestEngine.putModel = sinon.stub().callsFake(async (model) => { - _models[model.id] = model.toData(); + _models[model.id] = _.cloneDeep(model.toData()); }); TestEngine.putIndex = sinon.stub().callsFake(async (index) => { for (const [key, value] of Object.entries(index)) { - _index[key] = value; + _index[key] = _.cloneDeep(value); } }); TestEngine.getSearchIndexCompiled = sinon.stub().callsFake(async (model) => { - if (_searchIndexCompiled[model.toString()]) return _searchIndexCompiled[model.toString()]; + if (_searchIndexCompiled[model.toString()]) return _.cloneDeep(_searchIndexCompiled[model.toString()]); throw new Error(`Search index does not exist for ${model.name}`); }); TestEngine.getSearchIndexRaw = sinon.stub().callsFake(async (model) => { - if (_searchIndexRaw[model.toString()]) return _searchIndexRaw[model.toString()]; + if (_searchIndexRaw[model.toString()]) return _.cloneDeep(_searchIndexRaw[model.toString()]); return {}; }); TestEngine.putSearchIndexCompiled = sinon.stub().callsFake(async (model, compiledIndex) => { - _searchIndexCompiled[model.toString()] = compiledIndex; + _searchIndexCompiled[model.toString()] = _.cloneDeep(compiledIndex); }); TestEngine.putSearchIndexRaw = sinon.stub().callsFake(async (model, rawIndex) => { - _searchIndexCompiled[model.toString()] = rawIndex; + _searchIndexCompiled[model.toString()] = _.cloneDeep(rawIndex); }); TestEngine.findByValue = sinon.stub().callsFake(async (model, parameters) => { From 629b989cd332f5eaf2b0c5280784976f22e8b143 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Sat, 21 Sep 2024 08:49:14 +0100 Subject: [PATCH 02/20] test: allow stubbing fs without specifying a filesystem --- src/engine/FileEngine.test.js | 26 ++++++-------------------- test/mocks/fs.js | 2 +- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/engine/FileEngine.test.js b/src/engine/FileEngine.test.js index dc59964..e8e8408 100644 --- a/src/engine/FileEngine.test.js +++ b/src/engine/FileEngine.test.js @@ -46,7 +46,7 @@ test('FileEngine.get(MainModel, id) when id exists', async t => { }); test('FileEngine.get(MainModel, id) when id does not exist', async t => { - const filesystem = stubFs({}); + const filesystem = stubFs(); await t.throwsAsync( () => FileEngine.configure({ @@ -61,7 +61,7 @@ test('FileEngine.get(MainModel, id) when id does not exist', async t => { }); test('FileEngine.put(model)', async t => { - const filesystem = stubFs({}); + const filesystem = stubFs(); const model = getTestModelInstance(valid); await t.notThrowsAsync(() => FileEngine.configure({ @@ -282,14 +282,7 @@ test('FileEngine.put(model) when putting an index fails', async t => { }); test('FileEngine.put(model) when the engine fails to put a compiled search index', async t => { - const filesystem = stubFs({ - 'MainModel/_index.json': { - 'MainModel/111111111111': { - id: 'MainModel/111111111111', - string: 'String', - }, - }, - }); + const filesystem = stubFs(); filesystem.writeFile.callsFake(path => { if (path.endsWith('/_search_index.json')) { @@ -320,14 +313,7 @@ test('FileEngine.put(model) when the engine fails to put a compiled search index }); test('FileEngine.put(model) when the engine fails to put a raw search index', async t => { - const filesystem = stubFs({ - 'MainModel/_index.json': { - 'MainModel/111111111111': { - id: 'MainModel/111111111111', - string: 'String', - }, - }, - }); + const filesystem = stubFs(); filesystem.writeFile.callsFake(path => { if (path.endsWith('_search_index_raw.json')) { @@ -357,7 +343,7 @@ test('FileEngine.put(model) when the engine fails to put a raw search index', as }); test('FileEngine.put(model) when the initial model put fails', async t => { - const filesystem = stubFs({}); + const filesystem = stubFs(); filesystem.writeFile.callsFake(path => { if (path.endsWith('MainModel/000000000000.json')) { @@ -381,7 +367,7 @@ test('FileEngine.put(model) when the initial model put fails', async t => { }); test('FileEngine.put(model) when the engine fails to put a linked model', async t => { - const filesystem = stubFs({}); + const filesystem = stubFs(); filesystem.writeFile.callsFake(path => { if (path.endsWith('LinkedModel/000000000000.json')) { diff --git a/test/mocks/fs.js b/test/mocks/fs.js index e263e01..affe6a5 100644 --- a/test/mocks/fs.js +++ b/test/mocks/fs.js @@ -2,7 +2,7 @@ import Model from '../../src/type/Model.js'; import lunr from 'lunr'; import sinon from 'sinon'; -function stubFs(filesystem, models = []) { +function stubFs(filesystem = {}, models = []) { const modelsAddedToFilesystem = []; function fileSystemFromModels(initialFilesystem = {}, ...models) { From 2c71b6ae561b46a6bb545b318fbd02ed58164c07 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Sat, 21 Sep 2024 11:33:31 +0100 Subject: [PATCH 03/20] refactor: make Engine.configuration public --- src/Persist.test.js | 6 +-- src/engine/Engine.api.test.js | 4 +- src/engine/Engine.js | 4 +- src/engine/Engine.test.js | 4 +- src/engine/FileEngine.js | 36 ++++++------- src/engine/FileEngine.test.js | 4 +- src/engine/HTTPEngine.js | 38 ++++++------- src/engine/HTTPEngine.test.js | 6 +-- src/engine/S3Engine.js | 62 +++++++++++----------- src/engine/S3Engine.test.js | 4 +- test/acceptance/engines/FileEngine.test.js | 6 +-- test/acceptance/engines/HTTPEngine.test.js | 6 +-- test/acceptance/engines/S3Engine.test.js | 6 +-- 13 files changed, 93 insertions(+), 93 deletions(-) diff --git a/src/Persist.test.js b/src/Persist.test.js index 91b1832..e5ae8eb 100644 --- a/src/Persist.test.js +++ b/src/Persist.test.js @@ -5,7 +5,7 @@ import test from 'ava'; class TestEngine { static configure(configuration = {}) { class ConfiguredTestEngine extends TestEngine { - static _configuration = configuration; + static configuration = configuration; } return ConfiguredTestEngine; @@ -19,13 +19,13 @@ test('includes Type', async t => { test('.addEngine(group, engine, configuration) adds and configures an engine', t => { Persist.addEngine('one', TestEngine, {test: true}); - t.like(Persist._engine.one.TestEngine._configuration, {test: true}); + t.like(Persist._engine.one.TestEngine.configuration, {test: true}); }); test('.getEngine(group, engine) retrieves an engine', t => { Persist.addEngine('one', TestEngine, {test: true}); - t.like(Persist.getEngine('one', TestEngine)._configuration, {test: true}); + t.like(Persist.getEngine('one', TestEngine).configuration, {test: true}); }); test('.getEngine(group, nonEngine) retrieves no engines', t => { diff --git a/src/engine/Engine.api.test.js b/src/engine/Engine.api.test.js index 6a4c095..dc89367 100644 --- a/src/engine/Engine.api.test.js +++ b/src/engine/Engine.api.test.js @@ -55,8 +55,8 @@ for (const {engine, configuration, configurationIgnores} of engines) { } } - t.like(configuredStore._configuration, checkConfiguration); - t.is(originalStore._configuration, undefined); + t.like(configuredStore.configuration, checkConfiguration); + t.assert(originalStore.configuration === undefined); }); test(`${engine.toString()}.get(MainModel, id) throws MissConfiguredError when engine is not configured`, async t => { diff --git a/src/engine/Engine.js b/src/engine/Engine.js index f7f2d79..f16f8ff 100644 --- a/src/engine/Engine.js +++ b/src/engine/Engine.js @@ -6,7 +6,7 @@ import lunr from 'lunr'; * @class Engine */ export default class Engine { - static _configuration = undefined; + static configuration = undefined; static async getById(_id) { throw new NotImplementedError(`${this.name} must implement .getById()`); @@ -200,7 +200,7 @@ export default class Engine { static configure(configuration) { class ConfiguredStore extends this { - static _configuration = configuration; + static configuration = configuration; } Object.defineProperty(ConfiguredStore, 'name', {value: `${this.toString()}`}); diff --git a/src/engine/Engine.test.js b/src/engine/Engine.test.js index d8acdfa..a99a3ea 100644 --- a/src/engine/Engine.test.js +++ b/src/engine/Engine.test.js @@ -11,8 +11,8 @@ test('Engine.configure returns a new store without altering the exising one', t const originalStore = Engine; const configuredStore = Engine.configure({}); - t.deepEqual(configuredStore._configuration, {}); - t.is(originalStore._configuration, undefined); + t.deepEqual(configuredStore.configuration, {}); + t.assert(originalStore.configuration === undefined); }); test('UnimplementedEngine.get(Model, id) raises a getById not implemented error', async t => { diff --git a/src/engine/FileEngine.js b/src/engine/FileEngine.js index 5afc411..b9d5009 100644 --- a/src/engine/FileEngine.js +++ b/src/engine/FileEngine.js @@ -20,27 +20,27 @@ export default class FileEngine extends Engine { static checkConfiguration() { if ( - !this._configuration?.path || - !this._configuration?.filesystem - ) throw new MissConfiguredError(this._configuration); + !this.configuration?.path || + !this.configuration?.filesystem + ) throw new MissConfiguredError(this.configuration); } static async getById(id) { - const filePath = join(this._configuration.path, `${id}.json`); + const filePath = join(this.configuration.path, `${id}.json`); - return JSON.parse(await this._configuration.filesystem.readFile(filePath).then(f => f.toString())); + return JSON.parse(await this.configuration.filesystem.readFile(filePath).then(f => f.toString())); } static async getIndex(model) { - return JSON.parse((await this._configuration.filesystem.readFile(join(this._configuration.path, model.name, '_index.json')).catch(() => '{}')).toString()); + return JSON.parse((await this.configuration.filesystem.readFile(join(this.configuration.path, model.name, '_index.json')).catch(() => '{}')).toString()); } static async putModel(model) { - const filePath = join(this._configuration.path, `${model.id}.json`); + const filePath = join(this.configuration.path, `${model.id}.json`); try { - await this._configuration.filesystem.mkdir(dirname(filePath), {recursive: true}); - await this._configuration.filesystem.writeFile(filePath, JSON.stringify(model.toData())); + await this.configuration.filesystem.mkdir(dirname(filePath), {recursive: true}); + await this.configuration.filesystem.writeFile(filePath, JSON.stringify(model.toData())); } catch (error) { throw new FailedWriteFileEngineError(`Failed to put file://${filePath}`, error); } @@ -49,11 +49,11 @@ export default class FileEngine extends Engine { static async putIndex(index) { const processIndex = async (location, models) => { const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()])); - const filePath = join(this._configuration.path, location, '_index.json'); - const currentIndex = JSON.parse((await this._configuration.filesystem.readFile(filePath).catch(() => '{}')).toString()); + const filePath = join(this.configuration.path, location, '_index.json'); + const currentIndex = JSON.parse((await this.configuration.filesystem.readFile(filePath).catch(() => '{}')).toString()); try { - await this._configuration.filesystem.writeFile(filePath, JSON.stringify({ + await this.configuration.filesystem.writeFile(filePath, JSON.stringify({ ...currentIndex, ...modelIndex, })); @@ -70,32 +70,32 @@ export default class FileEngine extends Engine { } static async getSearchIndexCompiled(model) { - return await this._configuration.filesystem.readFile(join(this._configuration.path, model.name, '_search_index.json')) + return await this.configuration.filesystem.readFile(join(this.configuration.path, model.name, '_search_index.json')) .then(b => b.toString()) .then(JSON.parse); } static async getSearchIndexRaw(model) { - return await this._configuration.filesystem.readFile(join(this._configuration.path, model.name, '_search_index_raw.json')) + return await this.configuration.filesystem.readFile(join(this.configuration.path, model.name, '_search_index_raw.json')) .then(b => b.toString()) .then(JSON.parse) .catch(() => ({})); } static async putSearchIndexCompiled(model, compiledIndex) { - const filePath = join(this._configuration.path, model.name, '_search_index.json'); + const filePath = join(this.configuration.path, model.name, '_search_index.json'); try { - await this._configuration.filesystem.writeFile(filePath, JSON.stringify(compiledIndex)); + await this.configuration.filesystem.writeFile(filePath, JSON.stringify(compiledIndex)); } catch (error) { throw new FailedWriteFileEngineError(`Failed to put file://${filePath}`, error); } } static async putSearchIndexRaw(model, rawIndex) { - const filePath = join(this._configuration.path, model.name, '_search_index_raw.json'); + const filePath = join(this.configuration.path, model.name, '_search_index_raw.json'); try { - await this._configuration.filesystem.writeFile(filePath, JSON.stringify(rawIndex)); + await this.configuration.filesystem.writeFile(filePath, JSON.stringify(rawIndex)); } catch (error) { throw new FailedWriteFileEngineError(`Failed to put file://${filePath}`, error); } diff --git a/src/engine/FileEngine.test.js b/src/engine/FileEngine.test.js index e8e8408..0c0b9f1 100644 --- a/src/engine/FileEngine.test.js +++ b/src/engine/FileEngine.test.js @@ -10,8 +10,8 @@ test('FileEngine.configure(configuration) returns a new engine without altering const originalStore = FileEngine; const configuredStore = originalStore.configure({path: '/tmp/fileEngine'}); - t.deepEqual(configuredStore._configuration, {path: '/tmp/fileEngine', filesystem: fs}); - t.is(originalStore._configuration, undefined); + t.deepEqual(configuredStore.configuration, {path: '/tmp/fileEngine', filesystem: fs}); + t.assert(originalStore.configuration === undefined); }); test('FileEngine.get(MainModel, id) when engine is not configured', async t => { diff --git a/src/engine/HTTPEngine.js b/src/engine/HTTPEngine.js index 09f0e15..3e149b7 100644 --- a/src/engine/HTTPEngine.js +++ b/src/engine/HTTPEngine.js @@ -28,12 +28,12 @@ export default class HTTPEngine extends Engine { static checkConfiguration() { if ( - !this._configuration?.host - ) throw new MissConfiguredError(this._configuration); + !this.configuration?.host + ) throw new MissConfiguredError(this.configuration); } static _getReadOptions() { - return this._configuration.fetchOptions; + return this.configuration.fetchOptions; } static _getWriteOptions() { @@ -48,7 +48,7 @@ export default class HTTPEngine extends Engine { } static async _processFetch(url, options, defaultValue = undefined) { - return this._configuration.fetch(url, options) + return this.configuration.fetch(url, options) .then(response => { if (!response.ok) { if (defaultValue !== undefined) { @@ -67,8 +67,8 @@ export default class HTTPEngine extends Engine { this.checkConfiguration(); const url = new URL([ - this._configuration.host, - this._configuration.prefix, + this.configuration.host, + this.configuration.prefix, `${id}.json`, ].filter(e => !!e).join('/')); @@ -77,8 +77,8 @@ export default class HTTPEngine extends Engine { static async putModel(model) { const url = new URL([ - this._configuration.host, - this._configuration.prefix, + this.configuration.host, + this.configuration.prefix, `${model.id}.json`, ].filter(e => !!e).join('/')); @@ -92,8 +92,8 @@ export default class HTTPEngine extends Engine { const processIndex = async (location, models) => { const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()])); const url = new URL([ - this._configuration.host, - this._configuration.prefix, + this.configuration.host, + this.configuration.prefix, location, '_index.json', ].filter(e => !!e).join('/')); @@ -115,15 +115,15 @@ export default class HTTPEngine extends Engine { } static async getIndex(location) { - const url = new URL([this._configuration.host, this._configuration.prefix, location, '_index.json'].filter(e => !!e).join('/')); + const url = new URL([this.configuration.host, this.configuration.prefix, location, '_index.json'].filter(e => !!e).join('/')); return await this._processFetch(url, this._getReadOptions(), {}); } static async getSearchIndexCompiled(model) { const url = new URL([ - this._configuration.host, - this._configuration.prefix, + this.configuration.host, + this.configuration.prefix, model.toString(), '_search_index.json', ].join('/')); @@ -133,8 +133,8 @@ export default class HTTPEngine extends Engine { static async getSearchIndexRaw(model) { const url = new URL([ - this._configuration.host, - this._configuration.prefix, + this.configuration.host, + this.configuration.prefix, model.toString(), '_search_index_raw.json', ].join('/')); @@ -144,8 +144,8 @@ export default class HTTPEngine extends Engine { static async putSearchIndexCompiled(model, compiledIndex) { const url = new URL([ - this._configuration.host, - this._configuration.prefix, + this.configuration.host, + this.configuration.prefix, model.name, '_search_index.json', ].filter(e => !!e).join('/')); @@ -158,8 +158,8 @@ export default class HTTPEngine extends Engine { static async putSearchIndexRaw(model, rawIndex) { const url = new URL([ - this._configuration.host, - this._configuration.prefix, + this.configuration.host, + this.configuration.prefix, model.name, '_search_index_raw.json', ].filter(e => !!e).join('/')); diff --git a/src/engine/HTTPEngine.test.js b/src/engine/HTTPEngine.test.js index 2187430..ae8191a 100644 --- a/src/engine/HTTPEngine.test.js +++ b/src/engine/HTTPEngine.test.js @@ -14,12 +14,12 @@ test('HTTPEngine.configure(configuration) returns a new engine without altering fetch, }); - t.is(originalStore._configuration, undefined); - t.like(configuredStore._configuration, { + t.like(configuredStore.configuration, { host: 'https://example.com', prefix: 'test', fetch, }); + t.assert(originalStore.configuration === undefined); }); test('HTTPEngine.configure(configuration) with additional headers returns a new engine with the headers', t => { @@ -34,7 +34,7 @@ test('HTTPEngine.configure(configuration) with additional headers returns a new fetch, }); - t.is(originalStore._configuration, undefined); + t.assert(originalStore.configuration === undefined); t.like(configuredStore._getReadOptions(), { headers: { Authorization: 'Bearer some-bearer-token-for-authentication', diff --git a/src/engine/S3Engine.js b/src/engine/S3Engine.js index 82b3d35..98d9548 100644 --- a/src/engine/S3Engine.js +++ b/src/engine/S3Engine.js @@ -8,16 +8,16 @@ class FailedPutS3EngineError extends S3EngineError {} export default class S3Engine extends Engine { static checkConfiguration() { if ( - !this._configuration?.bucket || - !this._configuration?.client - ) throw new MissConfiguredError(this._configuration); + !this.configuration?.bucket || + !this.configuration?.client + ) throw new MissConfiguredError(this.configuration); } static async getById(id) { - const objectPath = [this._configuration.prefix, `${id}.json`].join('/'); + const objectPath = [this.configuration.prefix, `${id}.json`].join('/'); - const data = await this._configuration.client.send(new GetObjectCommand({ - Bucket: this._configuration.bucket, + const data = await this.configuration.client.send(new GetObjectCommand({ + Bucket: this.configuration.bucket, Key: objectPath, })); @@ -25,25 +25,25 @@ export default class S3Engine extends Engine { } static async putModel(model) { - const Key = [this._configuration.prefix, `${model.id}.json`].join('/'); + const Key = [this.configuration.prefix, `${model.id}.json`].join('/'); try { - await this._configuration.client.send(new PutObjectCommand({ + await this.configuration.client.send(new PutObjectCommand({ Key, Body: JSON.stringify(model.toData()), - Bucket: this._configuration.bucket, + Bucket: this.configuration.bucket, ContentType: 'application/json', })); } catch (error) { - throw new FailedPutS3EngineError(`Failed to put s3://${this._configuration.bucket}/${Key}`, error); + throw new FailedPutS3EngineError(`Failed to put s3://${this.configuration.bucket}/${Key}`, error); } } static async getIndex(location) { try { - const data = await this._configuration.client.send(new GetObjectCommand({ - Key: [this._configuration.prefix, location, '_index.json'].filter(e => !!e).join('/'), - Bucket: this._configuration.bucket, + const data = await this.configuration.client.send(new GetObjectCommand({ + Key: [this.configuration.prefix, location, '_index.json'].filter(e => !!e).join('/'), + Bucket: this.configuration.bucket, })); return JSON.parse(await data.Body.transformToString()); @@ -55,14 +55,14 @@ export default class S3Engine extends Engine { static async putIndex(index) { const processIndex = async (location, models) => { const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()])); - const Key = [this._configuration.prefix, location, '_index.json'].filter(e => !!e).join('/'); + const Key = [this.configuration.prefix, location, '_index.json'].filter(e => !!e).join('/'); const currentIndex = await this.getIndex(location); try { - await this._configuration.client.send(new PutObjectCommand({ + await this.configuration.client.send(new PutObjectCommand({ Key, - Bucket: this._configuration.bucket, + Bucket: this.configuration.bucket, ContentType: 'application/json', Body: JSON.stringify({ ...currentIndex, @@ -70,7 +70,7 @@ export default class S3Engine extends Engine { }), })); } catch (error) { - throw new FailedPutS3EngineError(`Failed to put s3://${this._configuration.bucket}/${Key}`, error); + throw new FailedPutS3EngineError(`Failed to put s3://${this.configuration.bucket}/${Key}`, error); } }; @@ -82,49 +82,49 @@ export default class S3Engine extends Engine { } static async getSearchIndexCompiled(model) { - return await this._configuration.client.send(new GetObjectCommand({ - Key: [this._configuration.prefix, model.name, '_search_index.json'].join('/'), - Bucket: this._configuration.bucket, + return await this.configuration.client.send(new GetObjectCommand({ + Key: [this.configuration.prefix, model.name, '_search_index.json'].join('/'), + Bucket: this.configuration.bucket, })).then(data => data.Body.transformToString()) .then(JSON.parse); } static async getSearchIndexRaw(model) { - return await this._configuration.client.send(new GetObjectCommand({ - Key: [this._configuration.prefix, model.name, '_search_index_raw.json'].join('/'), - Bucket: this._configuration.bucket, + return await this.configuration.client.send(new GetObjectCommand({ + Key: [this.configuration.prefix, model.name, '_search_index_raw.json'].join('/'), + Bucket: this.configuration.bucket, })).then(data => data.Body.transformToString()) .then(JSON.parse) .catch(() => ({})); } static async putSearchIndexCompiled(model, compiledIndex) { - const Key = [this._configuration.prefix, model.name, '_search_index.json'].join('/'); + const Key = [this.configuration.prefix, model.name, '_search_index.json'].join('/'); try { - await this._configuration.client.send(new PutObjectCommand({ + await this.configuration.client.send(new PutObjectCommand({ Key, Body: JSON.stringify(compiledIndex), - Bucket: this._configuration.bucket, + Bucket: this.configuration.bucket, ContentType: 'application/json', })); } catch (error) { - throw new FailedPutS3EngineError(`Failed to put s3://${this._configuration.bucket}/${Key}`, error); + throw new FailedPutS3EngineError(`Failed to put s3://${this.configuration.bucket}/${Key}`, error); } } static async putSearchIndexRaw(model, rawIndex) { - const Key = [this._configuration.prefix, model.name, '_search_index_raw.json'].join('/'); + const Key = [this.configuration.prefix, model.name, '_search_index_raw.json'].join('/'); try { - await this._configuration.client.send(new PutObjectCommand({ + await this.configuration.client.send(new PutObjectCommand({ Key, Body: JSON.stringify(rawIndex), - Bucket: this._configuration.bucket, + Bucket: this.configuration.bucket, ContentType: 'application/json', })); } catch (error) { - throw new FailedPutS3EngineError(`Failed to put s3://${this._configuration.bucket}/${Key}`, error); + throw new FailedPutS3EngineError(`Failed to put s3://${this.configuration.bucket}/${Key}`, error); } } } diff --git a/src/engine/S3Engine.test.js b/src/engine/S3Engine.test.js index 3fb7d95..3bd3c2c 100644 --- a/src/engine/S3Engine.test.js +++ b/src/engine/S3Engine.test.js @@ -14,11 +14,11 @@ test('S3Engine.configure(configuration) returns a new engine without altering th client: stubS3Client(), }); - t.is(originalStore._configuration, undefined); - t.like(configuredStore._configuration, { + t.like(configuredStore.configuration, { bucket: 'test-bucket', prefix: 'test', }); + t.assert(originalStore.configuration === undefined); }); test('S3Engine.get(MainModel, id) when engine is not configured', async t => { diff --git a/test/acceptance/engines/FileEngine.test.js b/test/acceptance/engines/FileEngine.test.js index 200d77c..b6e4f4b 100644 --- a/test/acceptance/engines/FileEngine.test.js +++ b/test/acceptance/engines/FileEngine.test.js @@ -8,7 +8,7 @@ test('Persist allows adding the FileEngine', t => { path: '/tmp/fileEngine', }); - t.like(Persist._engine.files.FileEngine._configuration, { + t.like(Persist._engine.files.FileEngine.configuration, { path: '/tmp/fileEngine', filesystem: fs, }); @@ -20,7 +20,7 @@ test('Persist allows adding the FileEngine with transactions', t => { transactions: true, }); - t.like(Persist._engine.files.FileEngine._configuration, { + t.like(Persist._engine.files.FileEngine.configuration, { path: '/tmp/fileEngine', filesystem: fs, transactions: true, @@ -34,7 +34,7 @@ test('Persist allows retrieving a FileEngine', t => { path: '/tmp/fileEngine', }); - t.like(Persist.getEngine('files', FileEngine)._configuration, { + t.like(Persist.getEngine('files', FileEngine).configuration, { path: '/tmp/fileEngine', filesystem: fs, }); diff --git a/test/acceptance/engines/HTTPEngine.test.js b/test/acceptance/engines/HTTPEngine.test.js index ef50adb..70dd433 100644 --- a/test/acceptance/engines/HTTPEngine.test.js +++ b/test/acceptance/engines/HTTPEngine.test.js @@ -8,7 +8,7 @@ test('Persist allows adding the HTTPEngine', t => { prefix: 'test', }); - t.like(Persist._engine.http.HTTPEngine._configuration, { + t.like(Persist._engine.http.HTTPEngine.configuration, { host: 'https://example.com', prefix: 'test', }); @@ -21,7 +21,7 @@ test('Persist allows adding the HTTPEngine with transactions', t => { transactions: true, }); - t.like(Persist._engine.http.HTTPEngine._configuration, { + t.like(Persist._engine.http.HTTPEngine.configuration, { host: 'https://example.com', prefix: 'test', transactions: true, @@ -36,7 +36,7 @@ test('Persist allows retrieving a HTTPEngine', t => { prefix: 'test', }); - t.like(Persist.getEngine('http', HTTPEngine)._configuration, { + t.like(Persist.getEngine('http', HTTPEngine).configuration, { host: 'https://example.com', prefix: 'test', }); diff --git a/test/acceptance/engines/S3Engine.test.js b/test/acceptance/engines/S3Engine.test.js index 6b501e4..007e6f7 100644 --- a/test/acceptance/engines/S3Engine.test.js +++ b/test/acceptance/engines/S3Engine.test.js @@ -8,7 +8,7 @@ test('Persist allows adding the S3Engine', t => { prefix: 'test', }); - t.like(Persist._engine.s3.S3Engine._configuration, { + t.like(Persist._engine.s3.S3Engine.configuration, { bucket: 'test-bucket', prefix: 'test', }); @@ -21,7 +21,7 @@ test('Persist allows adding the S3Engine with transactions', t => { transactions: true, }); - t.like(Persist._engine.s3.S3Engine._configuration, { + t.like(Persist._engine.s3.S3Engine.configuration, { bucket: 'test-bucket', prefix: 'test', transactions: true, @@ -36,7 +36,7 @@ test('Persist allows retrieving a S3Engine', t => { prefix: 'test', }); - t.like(Persist.getEngine('s3', S3Engine)._configuration, { + t.like(Persist.getEngine('s3', S3Engine).configuration, { bucket: 'test-bucket', prefix: 'test', }); From 6ef2bdc12325801f5639a623008d4d5e8c78dd5a Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 14:10:41 +0100 Subject: [PATCH 04/20] fix: allow falsy matching queries --- src/Persist.js | 4 +- src/Query.js | 53 ++- src/Query.test.js | 195 ++++++----- src/SchemaCompiler.js | 85 ++++- src/SchemaCompiler.test.js | 79 ++++- src/Transactions.test.js | 31 +- src/engine/Engine.api.test.js | 59 ++-- src/engine/Engine.js | 146 ++++++++- src/engine/Engine.test.js | 20 +- src/engine/FileEngine.js | 4 +- src/engine/FileEngine.test.js | 333 +++++++------------ src/engine/HTTPEngine.js | 4 +- src/engine/HTTPEngine.test.js | 430 ++++++++++--------------- src/engine/S3Engine.js | 4 +- src/engine/S3Engine.test.js | 352 +++++++------------- src/type/Model.js | 159 +++++++-- src/type/Model.test.js | 90 +++--- src/type/Type.js | 12 +- src/type/Type.test.js | 8 +- src/type/complex/ArrayType.js | 4 +- src/type/complex/ArrayType.test.js | 143 ++------ src/type/complex/CustomType.js | 4 +- src/type/complex/CustomType.test.js | 8 +- src/type/resolved/ResolvedType.js | 4 +- src/type/resolved/ResolvedType.test.js | 4 +- src/type/resolved/SlugType.js | 4 +- src/type/resolved/SlugType.test.js | 4 +- src/type/simple/BooleanType.js | 4 +- src/type/simple/BooleanType.test.js | 8 +- src/type/simple/DateType.js | 9 +- src/type/simple/DateType.test.js | 20 +- src/type/simple/NumberType.js | 4 +- src/type/simple/NumberType.test.js | 8 +- src/type/simple/SimpleType.js | 4 +- src/type/simple/StringType.js | 4 +- src/type/simple/StringType.test.js | 8 +- test/fixtures/ModelCollection.js | 136 ++++++++ test/fixtures/Models.js | 64 ++++ test/fixtures/TestIndex.js | 20 -- test/fixtures/TestModel.js | 196 ----------- test/mocks/fs.js | 4 +- test/mocks/s3.js | 4 +- 42 files changed, 1381 insertions(+), 1355 deletions(-) create mode 100644 test/fixtures/ModelCollection.js create mode 100644 test/fixtures/Models.js delete mode 100644 test/fixtures/TestIndex.js delete mode 100644 test/fixtures/TestModel.js diff --git a/src/Persist.js b/src/Persist.js index 5c7cc48..9a9e9e0 100644 --- a/src/Persist.js +++ b/src/Persist.js @@ -4,7 +4,7 @@ import enableTransactions from './Transactions.js'; /** * @class Persist */ -export default class Persist { +class Persist { static _engine = {}; /** * @memberof Persist @@ -42,3 +42,5 @@ export default class Persist { engine.configure(configuration); } } + +export default Persist; diff --git a/src/Query.js b/src/Query.js index 9c89e03..4cfb673 100644 --- a/src/Query.js +++ b/src/Query.js @@ -1,39 +1,60 @@ /** - * persist query language features: - * - value match {title: 'test'} or {title: {$is: 'test'}} - * - contains match {list: {$contains: 'test'}} or {string: {$contains: 'es'}} - * - nested query {list: {$contains: {slug: 'test'}}} - * - deep nesting queries {list: {$contains: {string: {$contains: 'test'}}}} - */ - -/** - * @class Query + * The `Query` class is responsible for executing searches on an indexed dataset + * based on a structured query. It supports various query types including value matches, + * contains matches, and nested queries. + * + * @example + * // The object has the property `title` witch exactly equals `test`. + * const query = new Query({title: 'test'}); + * const query = new Query({title: {$is: 'test'}}); + * + * // The object has the property `list` witch contains the string `test`. + * const query = new Query({list: {$contains: 'test'}}); + * + * // The object has the property `string` witch contains the string `es`. + * const query = new Query({string: {$contains: 'es'}}); + * + * // The object has the property `list` contains an object + * // with a property `string` that contains the string `test`. + * const query = new Query({ + * list: { + * $contains: { + * string: { + * $contains: 'test' + * } + * } + * } + * }); */ class Query { + /** + * The query object that defines the search criteria. + * @type {Object} + */ query; /** + * Constructs a new `Query` instance with the provided query object. * - * @param {object} query + * @param {Object} query - The structured query object defining the search criteria. */ constructor(query) { this.query = query; } /** - * Using the input query, find records in an index that match + * Executes the query against a model's index and returns the matching results. * - * @param {typeof Model} model - * @param {object} index + * @param {Model.constructor} model - The model class that contains the `fromData` method for constructing models from data. + * @param {Object} index - The index dataset to search through. + * @returns {Array} The models that match the query. */ execute(model, index) { - const matchIs = (query) => !!query?.$is; + const matchIs = (query) => query?.$is !== undefined; const matchPrimitive = (query) => ['string', 'number', 'boolean'].includes(typeof query); const matchContains = (query) => !!query?.$contains; const matchesQuery = (subject, inputQuery = this.query) => { - if (!subject || !inputQuery) return false; - if (matchPrimitive(inputQuery)) return subject === inputQuery; if (matchIs(inputQuery)) diff --git a/src/Query.test.js b/src/Query.test.js index 13dbe66..5fffaf4 100644 --- a/src/Query.test.js +++ b/src/Query.test.js @@ -1,6 +1,6 @@ -import {MainModel} from '../test/fixtures/TestModel.js'; +import {MainModel} from '../test/fixtures/Models.js'; +import {Models} from '../test/fixtures/ModelCollection.js'; import Query from './Query.js'; -import {TestIndex} from '../test/fixtures/TestIndex.js'; import test from 'ava'; test('new Query(query) stores the query', t => { @@ -9,122 +9,117 @@ test('new Query(query) stores the query', t => { t.deepEqual(query.query, {string: 'test'}); }); -test('Query.execute(index) finds exact matches with primitive types', t => { +test('Query.execute(index) finds exact string matches with primitive type', t => { + const models = new Models(); + const model = models.createFullTestModel(); + const query = new Query({string: 'test'}); - const results = query.execute(MainModel, TestIndex); - - t.deepEqual(results, [ - MainModel.fromData({ - id: 'MainModel/000000000000', - string: 'test', - arrayOfString: ['test'], - linkedMany: [{ - id: 'LinkedManyModel/000000000000000', - string: 'test', - }], - }), - ]); + + const results = query.execute(MainModel, models.getIndex(MainModel)); + + t.like(results, [model.toIndexData()]); }); -test('Query.execute(index) finds exact matches with $is', t => { +test('Query.execute(index) finds exact string matches with $is', t => { + const models = new Models(); + const model = models.createFullTestModel(); + models.createFullTestModel({string: 'another test'}); + const query = new Query({string: {$is: 'test'}}); - const results = query.execute(MainModel, TestIndex); - - t.deepEqual(results, [ - MainModel.fromData({ - id: 'MainModel/000000000000', - string: 'test', - arrayOfString: ['test'], - linkedMany: [{ - id: 'LinkedManyModel/000000000000000', - string: 'test', - }], - }), - ]); + const results = query.execute(MainModel, models.getIndex(MainModel)); + + t.like(results, [model.toIndexData()]); +}); + +test('Query.execute(index) finds exact boolean matches with primitive type', t => { + const models = new Models(); + const model = models.createFullTestModel(); + + const query = new Query({boolean: false}); + const results = query.execute(MainModel, models.getIndex(MainModel)); + + t.like(results, [model.toIndexData()]); +}); + +test('Query.execute(index) finds exact boolean matches with $is', t => { + const models = new Models(); + const model = models.createFullTestModel(); + + const query = new Query({boolean: {$is: false}}); + const results = query.execute(MainModel, models.getIndex(MainModel)); + + t.like(results, [model.toIndexData()]); +}); + +test('Query.execute(index) finds exact number matches with $is', t => { + const models = new Models(); + const model = models.createFullTestModel(); + + const query = new Query({number: {$is: 24.3}}); + const results = query.execute(MainModel, models.getIndex(MainModel)); + + t.like(results, [model.toIndexData()]); +}); + +test('Query.execute(index) finds exact number matches with primitive type', t => { + const models = new Models(); + const model = models.createFullTestModel(); + + const query = new Query({number: 24.3}); + const results = query.execute(MainModel, models.getIndex(MainModel)); + + t.like(results, [model.toIndexData()]); }); test('Query.execute(index) finds matches containing for strings', t => { + const models = new Models(); + const model1 = models.createFullTestModel(); + const model2 = models.createFullTestModel(); + models.createFullTestModel({string: 'not matching'}); + + model2.string = 'testing'; + const query = new Query({string: {$contains: 'test'}}); - const results = query.execute(MainModel, TestIndex); - - t.deepEqual(results, [ - MainModel.fromData({ - id: 'MainModel/000000000000', - string: 'test', - arrayOfString: ['test'], - linkedMany: [{ - id: 'LinkedManyModel/000000000000000', - string: 'test', - }], - }), - MainModel.fromData({ - id: 'MainModel/111111111111', - string: 'testing', - arrayOfString: ['testing'], - linkedMany: [{ - id: 'LinkedManyModel/111111111111', - string: 'testing', - }], - }), + const results = query.execute(MainModel, models.getIndex(MainModel)); + + t.like(results, [ + model1.toIndexData(), + model2.toIndexData(), ]); }); test('Query.execute(index) finds matches containing for arrays', t => { + const models = new Models(); + const model = models.createFullTestModel(); + const query = new Query({arrayOfString: {$contains: 'test'}}); - const results = query.execute(MainModel, TestIndex); - - t.deepEqual(results, [ - MainModel.fromData({ - id: 'MainModel/000000000000', - string: 'test', - arrayOfString: ['test'], - linkedMany: [{ - id: 'LinkedManyModel/000000000000000', - string: 'test', - }], - }), - ]); + const results = query.execute(MainModel, models.getIndex(MainModel)); + + t.like(results, [model.toIndexData()]); }); test('Query.execute(index) finds exact matches for elements in arrays', t => { - const query = new Query({linkedMany: {$contains: {string: 'test'}}}); - const results = query.execute(MainModel, TestIndex); - - t.deepEqual(results, [ - MainModel.fromData({ - id: 'MainModel/000000000000', - string: 'test', - arrayOfString: ['test'], - linkedMany: [{ - id: 'LinkedManyModel/000000000000000', - string: 'test', - }], - }), - ]); + const models = new Models(); + const model = models.createFullTestModel(); + + const query = new Query({linkedMany: {$contains: {string: 'many'}}}); + const results = query.execute(MainModel, models.getIndex(MainModel)); + + t.like(results, [model.toIndexData()]); }); test('Query.execute(index) finds partial matches for elements in arrays', t => { - const query = new Query({linkedMany: {$contains: {string: {$contains: 'test'}}}}); - const results = query.execute(MainModel, TestIndex); - - t.deepEqual(results, [ - MainModel.fromData({ - id: 'MainModel/000000000000', - string: 'test', - arrayOfString: ['test'], - linkedMany: [{ - id: 'LinkedManyModel/000000000000000', - string: 'test', - }], - }), - MainModel.fromData({ - id: 'MainModel/111111111111', - string: 'testing', - arrayOfString: ['testing'], - linkedMany: [{ - id: 'LinkedManyModel/111111111111', - string: 'testing', - }], - }), + const models = new Models(); + const model1 = models.createFullTestModel(); + const model2 = models.createFullTestModel(); + + model2.linkedMany[0].string = 'many tests'; + + const query = new Query({linkedMany: {$contains: {string: {$contains: 'many'}}}}); + const results = query.execute(MainModel, models.getIndex(MainModel)); + + t.like(results, [ + model1.toIndexData(), + model2.toIndexData(), ]); }); diff --git a/src/SchemaCompiler.js b/src/SchemaCompiler.js index 5c95b3f..c621d40 100644 --- a/src/SchemaCompiler.js +++ b/src/SchemaCompiler.js @@ -4,13 +4,21 @@ import ajvErrors from 'ajv-errors'; import ajvFormats from 'ajv-formats'; /** - * @class SchemaCompiler + * A class responsible for compiling raw schema definitions into a format that can be validated using the AJV (Another JSON Validator) library. */ -export default class SchemaCompiler { +class SchemaCompiler { /** - * @method compile - * @param {Model|object} rawSchema - * @return {CompiledSchema} + * Compiles a raw schema into a validation-ready schema, and returns a class that extends `CompiledSchema`. + * + * This method converts a given schema into a JSON schema-like format, setting up properties, types, formats, and validation rules. + * It uses AJV for the validation process and integrates with model types and their specific validation rules. + * + * @param {Object|Model} rawSchema - The raw schema or model definition to be compiled. + * @returns {CompiledSchema} - A class that extends `CompiledSchema`, with the compiled schema and validator attached. + * + * @example + * const schemaClass = SchemaCompiler.compile(MyModelSchema); + * const isValid = schemaClass.validate(data); // Throws ValidationError if data is invalid. */ static compile(rawSchema) { const validation = new ajv({allErrors: true}); @@ -27,7 +35,7 @@ export default class SchemaCompiler { if (Type.Model.isModel(rawSchema)) { schema.required.push('id'); - schema.properties['id'] = {type: 'string'}; + schema.properties.id = {type: 'string'}; } for (const [name, type] of Object.entries(rawSchema)) { @@ -88,7 +96,20 @@ export default class SchemaCompiler { } class Schema extends CompiledSchema { + /** + * The compiled schema definition. + * @type {Object} + * @static + * @private + */ static _schema = schema; + + /** + * The AJV validator function compiled from the schema. + * @type {Function} + * @static + * @private + */ static _validator = validation.compile(schema); } @@ -96,20 +117,36 @@ export default class SchemaCompiler { } } + /** - * @class CompiledSchema - * @property {object} _schema - * @property {Function} _validator + * Represents a compiled schema used for validating data models. + * This class provides a mechanism to validate data using a precompiled schema and a validator function. */ export class CompiledSchema { + /** + * The schema definition for validation, typically a precompiled JSON schema or similar. + * @type {?Object} + * @static + * @private + */ static _schema = null; + + /** + * The validator function used to validate data against the schema. + * @type {?Function} + * @static + * @private + */ static _validator = null; /** - * @method validate - * @param data - * @return {boolean} - * @throws {ValidationError} + * Validates the given data against the compiled schema. + * + * If the data is an instance of a model, it will be converted to a plain object via `toData()` before validation. + * + * @param {Object|Model} data - The data or model instance to be validated. + * @returns {boolean} - Returns `true` if the data is valid according to the schema. + * @throws {ValidationError} - Throws a `ValidationError` if the data is invalid. */ static validate(data) { let inputData = Object.assign({}, data); @@ -127,15 +164,29 @@ export class CompiledSchema { } /** - * @class ValidationError - * @extends Error - * @property {object[]} errors - * @property {object} data + * Represents a validation error that occurs when a model or data fails validation. + * Extends the built-in JavaScript `Error` class. */ export class ValidationError extends Error { + /** + * Creates an instance of `ValidationError`. + * + * @param {Object} data - The data that failed validation. + * @param {Array} errors - A list of validation errors, each typically containing details about what failed. + */ constructor(data, errors) { super('Validation failed'); + /** + * An array of validation errors, containing details about each failed validation. + * @type {Array} + */ this.errors = errors; + /** + * The data that caused the validation error. + * @type {Object} + */ this.data = data; } } + +export default SchemaCompiler; diff --git a/src/SchemaCompiler.test.js b/src/SchemaCompiler.test.js index acaaa45..c6d6d19 100644 --- a/src/SchemaCompiler.test.js +++ b/src/SchemaCompiler.test.js @@ -1,5 +1,6 @@ -import {MainModel, getTestModelInstance, invalid, valid} from '../test/fixtures/TestModel.js'; import SchemaCompiler, {CompiledSchema, ValidationError} from './SchemaCompiler.js'; +import {MainModel} from '../test/fixtures/Models.js'; +import {Models} from '../test/fixtures/ModelCollection.js'; import Type from './type/index.js'; import test from 'ava'; @@ -34,6 +35,54 @@ const schema = { requiredArrayOfDate: Type.Array.of(Type.Date).required, }; +export const valid = { + custom: {test: 'string'}, + string: 'String', + requiredString: 'Required String', + number: 24.3, + requiredNumber: 12.2, + boolean: false, + requiredBoolean: true, + date: new Date().toISOString(), + requiredDate: new Date().toISOString(), + emptyArrayOfStrings: [], + emptyArrayOfNumbers: [], + emptyArrayOfBooleans: [], + emptyArrayOfDates: [], + arrayOfString: ['String'], + arrayOfNumber: [24.5], + arrayOfBoolean: [false], + arrayOfDate: [new Date().toISOString()], + requiredArrayOfString: ['String'], + requiredArrayOfNumber: [24.5], + requiredArrayOfBoolean: [false], + requiredArrayOfDate: [new Date().toISOString()], +}; + +export const invalid = { + custom: {test: 123, additional: false}, + string: false, + requiredString: undefined, + number: 'test', + requiredNumber: undefined, + boolean: 13.4, + requiredBoolean: undefined, + date: 'not-a-date', + requiredDate: undefined, + emptyArrayOfStrings: 'not-a-list', + emptyArrayOfNumbers: 'not-a-list', + emptyArrayOfBooleans: 'not-a-list', + emptyArrayOfDates: 'not-a-list', + arrayOfString: [true], + arrayOfNumber: ['string'], + arrayOfBoolean: [15.8], + arrayOfDate: ['not-a-date'], + requiredArrayOfString: [true], + requiredArrayOfNumber: ['string'], + requiredArrayOfBoolean: [15.8], + requiredArrayOfDate: ['not-a-date'], +}; + const invalidDataErrors = [{ instancePath: '', keyword: 'required', @@ -58,6 +107,18 @@ const invalidDataErrors = [{ message: 'must have required property \'requiredDate\'', params: {missingProperty: 'requiredDate'}, schemaPath: '#/required', +}, { + instancePath: '/custom', + keyword: 'additionalProperties', + message: 'must NOT have additional properties', + params: {additionalProperty: 'additional'}, + schemaPath: '#/properties/custom/additionalProperties', +}, { + instancePath: '/custom/test', + keyword: 'type', + message: 'must be string', + params: {type: 'string'}, + schemaPath: '#/properties/custom/properties/test/type', }, { instancePath: '/string', keyword: 'type', @@ -208,14 +269,10 @@ test('.compile(schema) has the given schema associated with it', t => { }); test('.compile(schema).validate(valid) returns true', t => { - delete valid.id; - - t.true(SchemaCompiler.compile(schema).validate(valid)); + t.assert(SchemaCompiler.compile(schema).validate(valid)); }); test('.compile(schema).validate(invalid) throws a ValidationError', t => { - delete invalid.id; - const error = t.throws( () => SchemaCompiler.compile(schema).validate(invalid), {instanceOf: ValidationError}, @@ -346,11 +403,17 @@ test('.compile(MainModel) has the given schema associated with it', t => { }); test('.compile(MainModel).validate(validModel) returns true', t => { - t.true(SchemaCompiler.compile(MainModel).validate(getTestModelInstance(valid))); + const model = new Models().createFullTestModel(); + t.true(SchemaCompiler.compile(MainModel).validate(model)); }); test('.compile(MainModel).validate(invalidModel) throws a ValidationError', t => { - const invalidModel = getTestModelInstance(invalid); + const invalidModel = new Models().createFullTestModel(invalid); + + invalidModel.circular.id = 'CircularModel/not-a-valid-id'; + invalidModel.circularMany[0].id = 'CircularManyModel/not-a-valid-id'; + invalidModel.linked.id = 'LinkedModel/not-a-valid-id'; + invalidModel.linkedMany[0].id = 'LinkedManyModel/not-a-valid-id'; t.plan(Object.keys(invalidModel).length + 6); diff --git a/src/Transactions.test.js b/src/Transactions.test.js index a20cc9e..e66c7a0 100644 --- a/src/Transactions.test.js +++ b/src/Transactions.test.js @@ -1,6 +1,7 @@ -import {LinkedModel, getTestModelInstance, valid} from '../test/fixtures/TestModel.js'; +import {LinkedModel, MainModel} from '../test/fixtures/Models.js'; import enableTransactions, {TransactionCommittedError} from './Transactions.js'; import {EngineError} from './engine/Engine.js'; +import {Models} from '../test/fixtures/ModelCollection.js'; import assertions from '../test/assertions.js'; import {getTestEngine} from '../test/mocks/engine.js'; import test from 'ava'; @@ -20,7 +21,7 @@ test('enableTransactions(Engine) leaves the original engine intact', t => { }); test('transaction.put(model) calls putModel(model) on transaction.commit()', async t => { - const model = getTestModelInstance(valid); + const model = new Models().createFullTestModel(); const testEngine = getTestEngine(); const transactionalEngine = enableTransactions(testEngine); @@ -36,7 +37,7 @@ test('transaction.put(model) calls putModel(model) on transaction.commit()', asy }); test('transaction.commit() throws an exception if the transaction was successfully commited before', async t => { - const model = getTestModelInstance(valid); + const model = new Models().createFullTestModel(); const testEngine = getTestEngine(); const transactionalEngine = enableTransactions(testEngine); @@ -53,7 +54,7 @@ test('transaction.commit() throws an exception if the transaction was successful }); test('transaction.commit() throws an exception if the transaction fails', async t => { - const model = getTestModelInstance(valid); + const model = new Models().createFullTestModel(); const testEngine = getTestEngine(); testEngine.putModel.callsFake(async () => { @@ -109,12 +110,10 @@ test('transaction.commit() reverts already commited changes if the transaction f }); test('transaction.commit() reverts already commited changes if the transaction fails for complex models', async t => { - const model = getTestModelInstance(valid); - const original = getTestModelInstance(valid); + const models = new Models(); + models.createFullTestModel(); - const testEngine = getTestEngine([original]); - - model.linked.string = 'updated'; + const testEngine = getTestEngine([...Object.values(models.models)]); testEngine.putModel.callsFake(async subject => { if (subject.string === 'updated') { @@ -126,6 +125,10 @@ test('transaction.commit() reverts already commited changes if the transaction f const transaction = transactionalEngine.start(); + const model = await transaction.hydrate(await transaction.get(MainModel, 'MainModel/000000000000')); + + model.linked.string = 'updated'; + await transaction.put(model); await t.throwsAsync( @@ -136,6 +139,12 @@ test('transaction.commit() reverts already commited changes if the transaction f }, ); - assertions.calledWith(t, testEngine.putModel, model); - assertions.calledWith(t, testEngine.putModel, original); + assertions.calledWith(t, testEngine.putModel, { + id: 'LinkedModel/000000000000', + string: 'updated', + }); + assertions.calledWith(t, testEngine.putModel, { + id: 'LinkedModel/000000000000', + string: 'test', + }); }); diff --git a/src/engine/Engine.api.test.js b/src/engine/Engine.api.test.js index dc89367..47b68ce 100644 --- a/src/engine/Engine.api.test.js +++ b/src/engine/Engine.api.test.js @@ -1,21 +1,24 @@ -import {MainModel, getTestModelInstance, valid} from '../../test/fixtures/TestModel.js'; import {MissConfiguredError, NotFoundEngineError} from './Engine.js'; import FileEngine from './FileEngine.js'; import HTTPEngine from './HTTPEngine.js'; +import {MainModel} from '../../test/fixtures/Models.js'; +import {Models} from '../../test/fixtures/ModelCollection.js'; import S3Engine from './S3Engine.js'; import stubFetch from '../../test/mocks/fetch.js'; import stubFs from '../../test/mocks/fs.js'; import stubS3Client from '../../test/mocks/s3.js'; import test from 'ava'; -const model = getTestModelInstance(valid); +const models = new Models(); +const model = models.createFullTestModel(); + const engines = [ { engine: S3Engine, configuration: () => ({ bucket: 'test-bucket', prefix: 'test', - client: stubS3Client({}, {'test-bucket': [model]}), + client: stubS3Client({}, {'test-bucket': Object.values(models.models)}), }), configurationIgnores: ['client'], }, @@ -23,14 +26,14 @@ const engines = [ engine: FileEngine, configuration: () => ({ path: '/tmp/fileEngine', - filesystem: stubFs({}, [model]), + filesystem: stubFs({}, Object.values(models.models)), }), configurationIgnores: ['filesystem'], }, { engine: HTTPEngine, configuration: () => { - const fetch = stubFetch({}, [getTestModelInstance(valid)]); + const fetch = stubFetch({}, Object.values(models.models)); return ({ host: 'https://example.com', @@ -75,13 +78,13 @@ for (const {engine, configuration, configurationIgnores} of engines) { const got = await store.get(MainModel, 'MainModel/000000000000'); - t.like(got, { - ...getTestModelInstance(valid).toData(), - date: new Date(valid.date), - requiredDate: new Date(valid.requiredDate), - arrayOfDate: [new Date(valid.arrayOfDate[0])], - requiredArrayOfDate: [new Date(valid.requiredArrayOfDate[0])], - }); + t.deepEqual(got, MainModel.fromData({ + ...model.toData(), + date: new Date(model.date), + requiredDate: new Date(model.requiredDate), + arrayOfDate: [new Date(model.arrayOfDate[0])], + requiredArrayOfDate: [new Date(model.requiredArrayOfDate[0])], + })); }); test(`${engine.toString()}.get(MainModel, id) throws NotFoundEngineError when no model exists`, async t => { @@ -110,9 +113,9 @@ for (const {engine, configuration, configurationIgnores} of engines) { test(`${engine.toString()}.put(model) puts a new model`, async t => { const store = engine.configure(configuration()); - const response = await store.put(getTestModelInstance({...valid, id: 'MainModel/111111111111'})); + const response = await store.put(MainModel.fromData({...model.toData(), id: 'MainModel/111111111111'})); - t.is(response, undefined); + t.assert(response === undefined); }); test(`${engine.toString()}.find(MainModel, parameters) throws MissConfiguredError when engine is not configured`, async t => { @@ -129,12 +132,9 @@ for (const {engine, configuration, configurationIgnores} of engines) { test(`${engine.toString()}.find(MainModel, parameters) returns an array of matching models`, async t => { const store = engine.configure(configuration()); - const found = await store.find(MainModel, {string: 'String'}); + const found = await store.find(MainModel, {string: 'test'}); - t.like(found, [{ - id: 'MainModel/000000000000', - string: 'String', - }]); + t.deepEqual(found, [MainModel.fromData(model.toIndexData())]); }); test(`${engine.toString()}.search(MainModel, 'string') throws MissConfiguredError when engine is not configured`, async t => { @@ -148,24 +148,21 @@ for (const {engine, configuration, configurationIgnores} of engines) { t.is(error.message, 'Engine is miss-configured'); }); - test(`${engine.toString()}.search(MainModel, 'Str') returns an array of matching models`, async t => { + test(`${engine.toString()}.search(MainModel, 'test') returns an array of matching models`, async t => { const store = engine.configure(configuration()); - const found = await store.search(MainModel, 'Str'); + const found = await store.search(MainModel, 'test'); t.like(found, [{ - model: { - id: 'MainModel/000000000000', - string: 'String', - number: 24.3, - boolean: false, - }, + model: MainModel.fromData(model.toData(false)), + ref: 'MainModel/000000000000', + score: 0.364, }]); }); test(`${engine.toString()}.hydrate(model) throws MissConfiguredError when engine is not configured`, async t => { const error = await t.throwsAsync( - async () => await engine.hydrate(getTestModelInstance().toData()), + async () => await engine.hydrate(new Models().createFullTestModel().toData()), { instanceOf: MissConfiguredError, }, @@ -177,17 +174,17 @@ for (const {engine, configuration, configurationIgnores} of engines) { test(`${engine.toString()}.hydrate(model) returns a hydrated model when the input model comes from ${engine.toString()}.find(MainModel, parameters)`, async t => { const store = engine.configure(configuration()); - const [found] = await store.find(MainModel, {string: 'String'}); + const [found] = await store.find(MainModel, {string: 'test'}); const hydrated = await store.hydrate(found); t.is(hydrated.linked.string, 'test'); }); - test(`${engine.toString()}.hydrate(model) returns a hydrated model when the input model comes from ${engine.toString()}.search(MainModel, 'Str')`, async t => { + test(`${engine.toString()}.hydrate(model) returns a hydrated model when the input model comes from ${engine.toString()}.search(MainModel, 'test')`, async t => { const store = engine.configure(configuration()); - const [found] = await store.search(MainModel, 'Str'); + const [found] = await store.search(MainModel, 'test'); const hydrated = await store.hydrate(found.model); diff --git a/src/engine/Engine.js b/src/engine/Engine.js index f16f8ff..0b21bcb 100644 --- a/src/engine/Engine.js +++ b/src/engine/Engine.js @@ -3,43 +3,112 @@ import Type from '../type/index.js'; import lunr from 'lunr'; /** + * The `Engine` class provides a base interface for implementing data storage and retrieval engines. + * It includes methods for handling models, indexes, and search functionality. + * * @class Engine */ -export default class Engine { +class Engine { static configuration = undefined; + /** + * Retrieves a model by its ID. This method must be implemented by subclasses. + * + * @param {string} _id - The ID of the model to retrieve. + * @throws {NotImplementedError} Throws if the method is not implemented. + * @abstract + */ static async getById(_id) { - throw new NotImplementedError(`${this.name} must implement .getById()`); + throw new NotImplementedError(`${this['name']} must implement .getById()`); } + /** + * Saves a model to the data store. This method must be implemented by subclasses. + * + * @param {Model} _data - The model data to save. + * @throws {NotImplementedError} Throws if the method is not implemented. + * @abstract + */ static async putModel(_data) { - throw new NotImplementedError(`${this.name} must implement .putModel()`); + throw new NotImplementedError(`${this['name']} must implement .putModel()`); } + /** + * Retrieves the index for a given model. This method must be implemented by subclasses. + * + * @param {Object} _model - The model to retrieve the index for. + * @throws {NotImplementedError} Throws if the method is not implemented. + * @abstract + */ static async getIndex(_model) { - throw new NotImplementedError(`${this.name} does not implement .getIndex()`); + throw new NotImplementedError(`${this['name']} does not implement .getIndex()`); } + /** + * Saves the index for a given model. This method must be implemented by subclasses. + * + * @param {Object} _index - The index data to save. + * @throws {NotImplementedError} Throws if the method is not implemented. + * @abstract + */ static async putIndex(_index) { - throw new NotImplementedError(`${this.name} does not implement .putIndex()`); + throw new NotImplementedError(`${this['name']} does not implement .putIndex()`); } + /** + * Retrieves the compiled search index for a model. This method must be implemented by subclasses. + * + * @param {Object} _model - The model to retrieve the compiled search index for. + * @throws {NotImplementedError} Throws if the method is not implemented. + * @abstract + */ static async getSearchIndexCompiled(_model) { - throw new NotImplementedError(`${this.name} does not implement .getSearchIndexCompiled()`); + throw new NotImplementedError(`${this['name']} does not implement .getSearchIndexCompiled()`); } + /** + * Retrieves the raw search index for a model. This method must be implemented by subclasses. + * + * @param {Object} _model - The model to retrieve the raw search index for. + * @throws {NotImplementedError} Throws if the method is not implemented. + * @abstract + */ static async getSearchIndexRaw(_model) { - throw new NotImplementedError(`${this.name} does not implement .getSearchIndexRaw()`); + throw new NotImplementedError(`${this['name']} does not implement .getSearchIndexRaw()`); } + /** + * Saves the compiled search index for a model. This method must be implemented by subclasses. + * + * @param {Object} _model - The model for which the compiled search index is saved. + * @param {Object} _compiledIndex - The compiled search index data. + * @throws {NotImplementedError} Throws if the method is not implemented. + * @abstract + */ static async putSearchIndexCompiled(_model, _compiledIndex) { - throw new NotImplementedError(`${this.name} does not implement .putSearchIndexCompiled()`); + throw new NotImplementedError(`${this['name']} does not implement .putSearchIndexCompiled()`); } + /** + * Saves the raw search index for a model. This method must be implemented by subclasses. + * + * @param {Object} _model - The model for which the raw search index is saved. + * @param {Object} _rawIndex - The raw search index data. + * @throws {NotImplementedError} Throws if the method is not implemented. + * @abstract + */ static async putSearchIndexRaw(_model, _rawIndex) { - throw new NotImplementedError(`${this.name} does not implement .putSearchIndexRaw()`); + throw new NotImplementedError(`${this['name']} does not implement .putSearchIndexRaw()`); } + /** + * Performs a search query on a model's index and returns the matching models. + * + * @param {Model.constructor} model - The model class. + * @param {object} query - The search query string. + * @returns {Array} An array of models matching the search query. + * @throws {EngineError} Throws if the search index is not available for the model. + */ static async search(model, query) { this.checkConfiguration(); @@ -55,6 +124,7 @@ export default class Engine { for (const result of results) { output.push({ ...result, + score: Number(result.score.toFixed(4)), model: await this.get(model, result.ref), }); } @@ -130,7 +200,7 @@ export default class Engine { return model.fromData(found); } catch (error) { if (error.constructor === NotImplementedError) throw error; - throw new NotFoundEngineError(`${this.name}.get(${id}) model not found`, error); + throw new NotFoundEngineError(`${this['name']}.get(${id}) model not found`, error); } } @@ -189,9 +259,11 @@ export default class Engine { function getSubModelClass(modelToProcess, name, isArray = false) { const constructorField = modelToProcess.constructor[name]; + if (constructorField instanceof Function && !constructorField.prototype) { return isArray ? constructorField()._items : constructorField(); } + return isArray ? constructorField._items : constructorField; } @@ -213,29 +285,79 @@ export default class Engine { } static toString() { - return this.name; + return this['name']; } -}; +} +/** + * Represents a general error that occurs within the engine. + * Extends the built-in `Error` class. + */ export class EngineError extends Error { + /** + * The underlying error that caused this engine error, if available. + * @type {Error|undefined} + */ underlyingError; + + /** + * Creates an instance of `EngineError`. + * + * @param {string} message - The error message. + * @param {Error} [error] - An optional underlying error that caused this error. + */ constructor(message, error = undefined) { super(message); this.underlyingError = error; } } +/** + * Represents an error that occurs when a requested resource or item is not found in the engine. + * Extends the `EngineError` class. + */ export class NotFoundEngineError extends EngineError { + /** + * Creates an instance of `NotFoundEngineError`. + * + * @param {string} message - The error message. + * @param {Error} [error] - An optional underlying error that caused this error. + */ } +/** + * Represents an error indicating that a certain method or functionality is not implemented in the engine. + * Extends the `EngineError` class. + */ export class NotImplementedError extends EngineError { + /** + * Creates an instance of `NotImplementedError`. + * + * @param {string} message - The error message. + * @param {Error} [error] - An optional underlying error that caused this error. + */ } +/** + * Represents an error indicating that the engine is misconfigured. + * Extends the `EngineError` class. + */ export class MissConfiguredError extends EngineError { + /** + * The configuration that led to the misconfiguration error. + * @type {Object} + */ configuration; + /** + * Creates an instance of `MissConfiguredError`. + * + * @param {Object} configuration - The configuration object that caused the misconfiguration. + */ constructor(configuration) { super('Engine is miss-configured'); this.configuration = configuration; } } + +export default Engine; diff --git a/src/engine/Engine.test.js b/src/engine/Engine.test.js index a99a3ea..832fe53 100644 --- a/src/engine/Engine.test.js +++ b/src/engine/Engine.test.js @@ -1,5 +1,5 @@ import Engine, {NotFoundEngineError, NotImplementedError} from './Engine.js'; -import {MainModel} from '../../test/fixtures/TestModel.js'; +import {MainModel} from '../../test/fixtures/Models.js'; import Type from '../type/index.js'; import test from 'ava'; @@ -17,7 +17,7 @@ test('Engine.configure returns a new store without altering the exising one', t test('UnimplementedEngine.get(Model, id) raises a getById not implemented error', async t => { const error = await t.throwsAsync(() => - UnimplementedEngine.get(Type.Model, 'TestModel/999999999999'), + UnimplementedEngine.get(MainModel, 'TestModel/999999999999'), {instanceOf: NotImplementedError}, ); t.is(error.message, 'UnimplementedEngine must implement .getById()'); @@ -41,23 +41,23 @@ test('UnimplementedEngine.putIndex(model) raises a putIndex not implemented erro test('UnimplementedEngine.find(Model, {param: value}) raises a getIndex not implemented error', async t => { const error = await t.throwsAsync(() => - UnimplementedEngine.find(Type.Model, {param: 'value'}), + UnimplementedEngine.find(MainModel, {param: 'value'}), {instanceOf: NotImplementedError}, ); t.is(error.message, 'UnimplementedEngine does not implement .getIndex()'); }); -test('UnimplementedEngine.getSearchIndexCompiled(Model, {param: value}) raises a getSearchIndexCompiled not implemented error', async t => { +test('UnimplementedEngine.getSearchIndexCompiled(Model) raises a getSearchIndexCompiled not implemented error', async t => { const error = await t.throwsAsync(() => - UnimplementedEngine.getSearchIndexCompiled(Type.Model, {param: 'value'}), + UnimplementedEngine.getSearchIndexCompiled(MainModel), {instanceOf: NotImplementedError}, ); t.is(error.message, 'UnimplementedEngine does not implement .getSearchIndexCompiled()'); }); -test('UnimplementedEngine.getSearchIndexRaw(Model, {param: value}) raises a getSearchIndexRaw not implemented error', async t => { +test('UnimplementedEngine.getSearchIndexRaw(Model) raises a getSearchIndexRaw not implemented error', async t => { const error = await t.throwsAsync(() => - UnimplementedEngine.getSearchIndexRaw(Type.Model, {param: 'value'}), + UnimplementedEngine.getSearchIndexRaw(MainModel), {instanceOf: NotImplementedError}, ); t.is(error.message, 'UnimplementedEngine does not implement .getSearchIndexRaw()'); @@ -65,7 +65,7 @@ test('UnimplementedEngine.getSearchIndexRaw(Model, {param: value}) raises a getS test('UnimplementedEngine.putSearchIndexCompiled(Model, {param: value}) raises a putSearchIndexCompiled not implemented error', async t => { const error = await t.throwsAsync(() => - UnimplementedEngine.putSearchIndexCompiled(Type.Model, {param: 'value'}), + UnimplementedEngine.putSearchIndexCompiled(MainModel, {param: 'value'}), {instanceOf: NotImplementedError}, ); t.is(error.message, 'UnimplementedEngine does not implement .putSearchIndexCompiled()'); @@ -73,14 +73,14 @@ test('UnimplementedEngine.putSearchIndexCompiled(Model, {param: value}) raises a test('UnimplementedEngine.putSearchIndexRaw(Model, {param: value}) raises a putSearchIndexRaw not implemented error', async t => { const error = await t.throwsAsync(() => - UnimplementedEngine.putSearchIndexRaw(Type.Model, {param: 'value'}), + UnimplementedEngine.putSearchIndexRaw(MainModel, {param: 'value'}), {instanceOf: NotImplementedError}, ); t.is(error.message, 'UnimplementedEngine does not implement .putSearchIndexRaw()'); }); class ImplementedEngine extends Engine { - static getById(_model, _id) { + static getById(_id) { return null; } } diff --git a/src/engine/FileEngine.js b/src/engine/FileEngine.js index b9d5009..aa723ba 100644 --- a/src/engine/FileEngine.js +++ b/src/engine/FileEngine.js @@ -10,7 +10,7 @@ class FailedWriteFileEngineError extends FileEngineError {} * @class FileEngine * @extends Engine */ -export default class FileEngine extends Engine { +class FileEngine extends Engine { static configure(configuration) { if (!configuration.filesystem) { configuration.filesystem = fs; @@ -101,3 +101,5 @@ export default class FileEngine extends Engine { } } } + +export default FileEngine; diff --git a/src/engine/FileEngine.test.js b/src/engine/FileEngine.test.js index 0c0b9f1..380c3e1 100644 --- a/src/engine/FileEngine.test.js +++ b/src/engine/FileEngine.test.js @@ -1,6 +1,7 @@ +import {CircularManyModel, CircularModel, LinkedManyModel, LinkedModel, MainModel} from '../../test/fixtures/Models.js'; import {EngineError, MissConfiguredError, NotFoundEngineError} from './Engine.js'; -import {MainModel, getTestModelInstance, valid} from '../../test/fixtures/TestModel.js'; import FileEngine from './FileEngine.js'; +import {Models} from '../../test/fixtures/ModelCollection.js'; import assertions from '../../test/assertions.js'; import fs from 'node:fs/promises'; import stubFs from '../../test/mocks/fs.js'; @@ -26,23 +27,20 @@ test('FileEngine.get(MainModel, id) when engine is not configured', async t => { }); test('FileEngine.get(MainModel, id) when id exists', async t => { - const filesystem = stubFs({ - 'MainModel/000000000000.json': getTestModelInstance(valid).toData(), - }); + const models = new Models(); + const model = models.createFullTestModel(); + + const filesystem = stubFs({}, Object.values(models.models)); - const model = await FileEngine.configure({ + const got = await FileEngine.configure({ path: '/tmp/fileEngine', filesystem, }).get(MainModel, 'MainModel/000000000000'); t.true(filesystem.readFile.calledWith('/tmp/fileEngine/MainModel/000000000000.json')); - t.true(model instanceof MainModel); - t.true(model.validate()); - t.like(model.toData(), { - ...valid, - stringSlug: 'string', - requiredStringSlug: 'required-string', - }); + t.true(got instanceof MainModel); + t.true(got.validate()); + t.like(got.toData(), model.toData()); }); test('FileEngine.get(MainModel, id) when id does not exist', async t => { @@ -63,74 +61,36 @@ test('FileEngine.get(MainModel, id) when id does not exist', async t => { test('FileEngine.put(model)', async t => { const filesystem = stubFs(); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); + await t.notThrowsAsync(() => FileEngine.configure({ path: '/tmp/fileEngine', filesystem, }).put(model)); assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/000000000000.json', JSON.stringify(model.toData())); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_index.json', JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_index.json', JSON.stringify(models.getIndex(MainModel))); assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/MainModel/_search_index_raw.json'); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index_raw.json', JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - })); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index.json', JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/000000000000', [0, 0.288]]], - invertedIndex: [['string', {_index: 0, string: {'MainModel/000000000000': {}}}]], - pipeline: ['stemmer'], - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index_raw.json', JSON.stringify(models.getRawSearchIndex(MainModel))); + + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index.json', JSON.stringify(models.getSearchIndex(MainModel))); assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedModel/000000000000.json', JSON.stringify(model.linked.toData())); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedModel/111111111111.json', JSON.stringify(model.requiredLinked.toData())); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedModel/_index.json', JSON.stringify({ - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedModel/000000000001.json', JSON.stringify(model.requiredLinked.toData())); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedModel/_index.json', JSON.stringify(models.getIndex(LinkedModel))); assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedManyModel/000000000000.json', JSON.stringify(model.linkedMany[0].toData())); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedManyModel/_index.json', JSON.stringify({ - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedManyModel/_index.json', JSON.stringify(models.getIndex(LinkedManyModel))); assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/CircularModel/000000000000.json', JSON.stringify(model.circular.toData())); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/CircularModel/_index.json', JSON.stringify({ - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/CircularModel/_index.json', JSON.stringify(models.getIndex(CircularModel))); assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/CircularManyModel/000000000000.json', JSON.stringify(model.circularMany[0].toData())); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/CircularManyModel/_index.json', JSON.stringify({ - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/CircularManyModel/_index.json', JSON.stringify(models.getIndex(CircularManyModel))); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/_index.json', JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/_index.json', JSON.stringify(models.getIndex())); }); test('FileEngine.put(model) updates existing search indexes', async t => { @@ -143,33 +103,38 @@ test('FileEngine.put(model) updates existing search indexes', async t => { }, }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); + await t.notThrowsAsync(() => FileEngine.configure({ path: '/tmp/fileEngine', filesystem, }).put(model)); + t.is(filesystem.readFile.getCalls().length, 7); + t.is(filesystem.writeFile.getCalls().length, 14); + assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/MainModel/_search_index_raw.json'); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index_raw.json', JSON.stringify({ - 'MainModel/111111111111': { - id: 'MainModel/111111111111', - string: 'String', + + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index_raw.json', JSON.stringify(models.getRawSearchIndex( + MainModel, + { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', + string: 'String', + }, }, - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', + ))); + + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index.json', JSON.stringify(models.getSearchIndex( + MainModel, + { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', + string: 'String', + }, }, - })); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index.json', JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/111111111111', [0, 0.182]], ['string/MainModel/000000000000', [0, 0.182]]], - invertedIndex: [['string', { - _index: 0, - string: {'MainModel/111111111111': {}, 'MainModel/000000000000': {}}, - }]], - pipeline: ['stemmer'], - })); + ))); }); test('FileEngine.put(model) updates existing indexes', async t => { @@ -180,60 +145,43 @@ test('FileEngine.put(model) updates existing indexes', async t => { string: 'String', }, }, + '_index.json': { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', + string: 'String', + }, + }, }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.notThrowsAsync(() => FileEngine.configure({ path: '/tmp/fileEngine', filesystem, }).put(model)); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_index.json', JSON.stringify({ - 'MainModel/111111111111': { - id: 'MainModel/111111111111', - string: 'String', - }, - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - })); - - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedModel/_index.json', JSON.stringify({ - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - })); - - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedManyModel/_index.json', JSON.stringify({ - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - })); - - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/CircularModel/_index.json', JSON.stringify({ - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - })); + t.is(filesystem.readFile.getCalls().length, 7); + t.is(filesystem.writeFile.getCalls().length, 14); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/CircularManyModel/_index.json', JSON.stringify({ - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - })); + assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/MainModel/_index.json'); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_index.json', JSON.stringify(models.getIndex( + MainModel, + { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', + string: 'String', + }, + }, + ))); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/_index.json', JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', + assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/_index.json'); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/_index.json', JSON.stringify(models.getIndex(undefined, { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], }, - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - })); + }))); }); test('FileEngine.put(model) when putting an index fails', async t => { @@ -252,7 +200,8 @@ test('FileEngine.put(model) when putting an index fails', async t => { } }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.throwsAsync(() => FileEngine.configure({ path: '/tmp/fileEngine', @@ -265,20 +214,15 @@ test('FileEngine.put(model) when putting an index fails', async t => { t.is(filesystem.readFile.getCalls().filter(c => c.args[0].endsWith('/_index.json')).length, 1); t.is(filesystem.writeFile.getCalls().filter(c => c.args[0].endsWith('/_index.json')).length, 1); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_index.json', JSON.stringify({ + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_index.json', JSON.stringify(models.getIndex( + MainModel, + { 'MainModel/111111111111': { id: 'MainModel/111111111111', string: 'String', }, - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, }, - )); + ))); }); test('FileEngine.put(model) when the engine fails to put a compiled search index', async t => { @@ -290,7 +234,8 @@ test('FileEngine.put(model) when the engine fails to put a compiled search index } }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.throwsAsync(() => FileEngine.configure({ path: '/tmp/fileEngine', @@ -303,13 +248,7 @@ test('FileEngine.put(model) when the engine fails to put a compiled search index t.is(filesystem.readFile.callCount, 1); t.is(filesystem.writeFile.getCalls().filter(c => c.args[0].endsWith('/_search_index.json')).length, 1); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index.json', JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/000000000000', [0, 0.288]]], - invertedIndex: [['string', {_index: 0, string: {'MainModel/000000000000': {}}}]], - pipeline: ['stemmer'], - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index.json', JSON.stringify(models.getSearchIndex(MainModel))); }); test('FileEngine.put(model) when the engine fails to put a raw search index', async t => { @@ -321,7 +260,8 @@ test('FileEngine.put(model) when the engine fails to put a raw search index', as } }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.throwsAsync(() => FileEngine.configure({ path: '/tmp/fileEngine', @@ -334,12 +274,7 @@ test('FileEngine.put(model) when the engine fails to put a raw search index', as t.is(filesystem.readFile.callCount, 1); t.is(filesystem.writeFile.getCalls().filter(c => c.args[0].endsWith('/_search_index_raw.json')).length, 1); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index_raw.json', JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index_raw.json', JSON.stringify(models.getRawSearchIndex(MainModel))); }); test('FileEngine.put(model) when the initial model put fails', async t => { @@ -351,7 +286,8 @@ test('FileEngine.put(model) when the initial model put fails', async t => { } }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.throwsAsync(() => FileEngine.configure({ path: '/tmp/fileEngine', @@ -375,7 +311,8 @@ test('FileEngine.put(model) when the engine fails to put a linked model', async } }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.throwsAsync(() => FileEngine.configure({ path: '/tmp/fileEngine', @@ -386,45 +323,31 @@ test('FileEngine.put(model) when the engine fails to put a linked model', async }); t.is(filesystem.readFile.callCount, 1); - t.is(filesystem.writeFile.callCount, 5); + t.is(filesystem.writeFile.callCount, 4); assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/MainModel/_search_index_raw.json'); assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/000000000000.json', JSON.stringify(model.toData())); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index_raw.json', JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index_raw.json', JSON.stringify(models.getRawSearchIndex(MainModel))); - assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index.json', JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/000000000000', [0, 0.288]]], - invertedIndex: [['string', {_index: 0, string: {'MainModel/000000000000': {}}}]], - pipeline: ['stemmer'], - })); + assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/MainModel/_search_index.json', JSON.stringify(models.getSearchIndex(MainModel))); assertions.calledWith(t, filesystem.writeFile, '/tmp/fileEngine/LinkedModel/000000000000.json', JSON.stringify(model.linked.toData())); }); test('FileEngine.find(MainModel, {string: "test"}) when a matching model exists', async t => { - const filesystem = stubFs({}, [ - getTestModelInstance(valid), - getTestModelInstance({ - id: 'MainModel/1111111111111', - string: 'another string', - }), - ]); + const models = new Models(); + const model = models.createFullTestModel(); - const models = await FileEngine.configure({ + const filesystem = stubFs({}, Object.values(models.models)); + + const found = await FileEngine.configure({ path: '/tmp/fileEngine', filesystem, - }).find(MainModel, {string: 'String'}); + }).find(MainModel, {string: 'test'}); - t.like(models, [{id: 'MainModel/000000000000', string: 'String'}]); + t.like(found, [model.toIndexData()]); }); test('FileEngine.find(MainModel, {string: "test"}) when a matching model does not exist', async t => { @@ -433,7 +356,7 @@ test('FileEngine.find(MainModel, {string: "test"}) when a matching model does no const models = await FileEngine.configure({ path: '/tmp/fileEngine', filesystem, - }).find(MainModel, {string: 'String'}); + }).find(MainModel, {string: 'test'}); assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/MainModel/_index.json'); @@ -451,57 +374,50 @@ test('FileEngine.find(MainModel, {string: "test"}) when no index exists', async t.deepEqual(models, []); }); -test('FileEngine.search(MainModel, "Str") when a matching model exists', async t => { - const filesystem = stubFs({}, [ - getTestModelInstance(valid), - getTestModelInstance({ - id: 'MainModel/1111111111111', - string: 'another string', - }), - ]); +test('FileEngine.search(MainModel, "test") when matching models exist', async t => { + const models = new Models(); + const model1 = models.createFullTestModel(); + const model2 = models.createFullTestModel(); + + model2.string = 'moving tests'; + + const filesystem = stubFs({}, Object.values(models.models)); const configuration = { path: '/tmp/fileEngine', filesystem, }; - const model0 = await FileEngine.configure(configuration).get(MainModel, 'MainModel/000000000000'); + const found = await FileEngine.configure(configuration).search(MainModel, 'test'); - const model1 = await FileEngine.configure(configuration).get(MainModel, 'MainModel/1111111111111'); - - const models = await FileEngine.configure(configuration).search(MainModel, 'Str'); - - t.like(models, [{ + t.like(found, [{ ref: 'MainModel/000000000000', - score: 0.211, - model: model0, + score: 0.666, + model: model1.toData(false), }, { - ref: 'MainModel/1111111111111', - score: 0.16, - model: model1, + ref: 'MainModel/000000000001', + score: 0.506, + model: model2.toData(false), }]); }); test('FileEngine.search(MainModel, "not-even-close-to-a-match") when no matching model exists', async t => { - const filesystem = stubFs({}, [ - getTestModelInstance(valid), - getTestModelInstance({ - id: 'MainModel/1111111111111', - string: 'another string', - }), - ]); + const models = new Models(); + models.createFullTestModel(); + + const filesystem = stubFs({}, Object.values(models.models)); const configuration = { path: '/tmp/fileEngine', filesystem, }; - const models = await FileEngine.configure(configuration).search(MainModel, 'not-even-close-to-a-match'); + const found = await FileEngine.configure(configuration).search(MainModel, 'not-even-close-to-a-match'); - t.deepEqual(models, []); + t.deepEqual(found, []); }); -test('FileEngine.search(MainModel, "Str") when no index exists for the model', async t => { +test('FileEngine.search(MainModel, "test") when no index exists for the model', async t => { const filesystem = stubFs({}, []); const configuration = { @@ -509,14 +425,15 @@ test('FileEngine.search(MainModel, "Str") when no index exists for the model', a filesystem, }; - await t.throwsAsync(async () => await FileEngine.configure(configuration).search(MainModel, 'Str'), { + await t.throwsAsync(async () => await FileEngine.configure(configuration).search(MainModel, 'test'), { instanceOf: EngineError, message: 'The model MainModel does not have a search index available.', }); }); test('FileEngine.hydrate(model)', async t => { - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); const dryModel = new MainModel(); dryModel.id = 'MainModel/000000000000'; @@ -531,7 +448,7 @@ test('FileEngine.hydrate(model)', async t => { assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/MainModel/000000000000.json'); assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/CircularModel/000000000000.json'); assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/LinkedModel/000000000000.json'); - assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/LinkedModel/111111111111.json'); + assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/LinkedModel/000000000001.json'); assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/LinkedManyModel/000000000000.json'); assertions.calledWith(t, filesystem.readFile, '/tmp/fileEngine/CircularManyModel/000000000000.json'); diff --git a/src/engine/HTTPEngine.js b/src/engine/HTTPEngine.js index 3e149b7..7f6e452 100644 --- a/src/engine/HTTPEngine.js +++ b/src/engine/HTTPEngine.js @@ -13,7 +13,7 @@ export class HTTPRequestFailedError extends HTTPEngineError { } } -export default class HTTPEngine extends Engine { +class HTTPEngine extends Engine { static configure(configuration = {}) { configuration.fetchOptions = { ...(configuration.fetchOptions ?? {}), @@ -170,3 +170,5 @@ export default class HTTPEngine extends Engine { }); } } + +export default HTTPEngine; diff --git a/src/engine/HTTPEngine.test.js b/src/engine/HTTPEngine.test.js index ae8191a..69e122b 100644 --- a/src/engine/HTTPEngine.test.js +++ b/src/engine/HTTPEngine.test.js @@ -1,12 +1,13 @@ +import {CircularManyModel, CircularModel, LinkedManyModel, LinkedModel, MainModel} from '../../test/fixtures/Models.js'; import {EngineError, MissConfiguredError, NotFoundEngineError} from './Engine.js'; -import {MainModel, getTestModelInstance, valid} from '../../test/fixtures/TestModel.js'; import HTTPEngine from './HTTPEngine.js'; +import {Models} from '../../test/fixtures/ModelCollection.js'; import assertions from '../../test/assertions.js'; import stubFetch from '../../test/mocks/fetch.js'; import test from 'ava'; test('HTTPEngine.configure(configuration) returns a new engine without altering the exising one', t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)]); + const fetch = stubFetch(); const originalStore = HTTPEngine; const configuredStore = originalStore.configure({ host: 'https://example.com', @@ -23,7 +24,7 @@ test('HTTPEngine.configure(configuration) returns a new engine without altering }); test('HTTPEngine.configure(configuration) with additional headers returns a new engine with the headers', t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)]); + const fetch = stubFetch(); const originalStore = HTTPEngine; const configuredStore = originalStore.configure({ host: 'https://example.com', @@ -62,9 +63,12 @@ test('HTTPEngine.get(MainModel, id) when engine is not configured', async t => { }); test('HTTPEngine.get(MainModel, id) when id exists', async t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)]); + const models = new Models(); + const model = models.createFullTestModel(); - const model = await HTTPEngine.configure({ + const fetch = stubFetch({}, Object.values(models.models)); + + const got = await HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', fetch, @@ -72,17 +76,16 @@ test('HTTPEngine.get(MainModel, id) when id exists', async t => { assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/000000000000.json'), {headers: {Accept: 'application/json'}}); - t.true(model instanceof MainModel); - t.true(model.validate()); - t.like(model.toData(), { - ...valid, - stringSlug: 'string', - requiredStringSlug: 'required-string', - }); + t.true(got instanceof MainModel); + t.true(got.validate()); + t.like(got.toData(), model.toData()); }); test('HTTPEngine.get(MainModel, id) when id does not exist', async t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)]); + const models = new Models(); + models.createFullTestModel(); + + const fetch = stubFetch({}, Object.values(models.models)); await t.throwsAsync( () => @@ -101,9 +104,10 @@ test('HTTPEngine.get(MainModel, id) when id does not exist', async t => { }); test('HTTPEngine.put(model)', async t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)]); + const models = new Models(); + const model = models.createFullTestModel(); - const model = getTestModelInstance(valid); + const fetch = stubFetch({}, Object.values(models.models)); await HTTPEngine.configure({ host: 'https://example.com', @@ -128,15 +132,7 @@ test('HTTPEngine.put(model)', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - }), + body: JSON.stringify(models.getIndex(MainModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_search_index_raw.json'), {headers: {Accept: 'application/json'}}); @@ -147,12 +143,7 @@ test('HTTPEngine.put(model)', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - }), + body: JSON.stringify(models.getRawSearchIndex(MainModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_search_index.json'), { @@ -161,13 +152,7 @@ test('HTTPEngine.put(model)', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/000000000000', [0, 0.288]]], - invertedIndex: [['string', {_index: 0, string: {'MainModel/000000000000': {}}}]], - pipeline: ['stemmer'], - }), + body: JSON.stringify(models.getSearchIndex(MainModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedModel/000000000000.json'), { @@ -179,7 +164,7 @@ test('HTTPEngine.put(model)', async t => { body: JSON.stringify(model.linked.toData()), }); - assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedModel/111111111111.json'), { + assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedModel/000000000001.json'), { headers: { Accept: 'application/json', 'Content-Type': 'application/json', @@ -196,10 +181,7 @@ test('HTTPEngine.put(model)', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - }), + body: JSON.stringify(models.getIndex(LinkedModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedManyModel/000000000000.json'), { @@ -219,9 +201,7 @@ test('HTTPEngine.put(model)', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - }), + body: JSON.stringify(models.getIndex(LinkedManyModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/CircularModel/000000000000.json'), { @@ -241,9 +221,7 @@ test('HTTPEngine.put(model)', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - }), + body: JSON.stringify(models.getIndex(CircularModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/CircularManyModel/000000000000.json'), { @@ -263,9 +241,7 @@ test('HTTPEngine.put(model)', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - }), + body: JSON.stringify(models.getIndex(CircularManyModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/_index.json'), {headers: {Accept: 'application/json'}}); @@ -276,25 +252,15 @@ test('HTTPEngine.put(model)', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - }), + body: JSON.stringify(models.getIndex()), }); }); test('HTTPEngine.put(model) when the engine fails to put a compiled search index', async t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)]); + const models = new Models(); + const model = models.createFullTestModel(); + + const fetch = stubFetch({}, Object.values(models.models)); fetch.callsFake((url) => { if (url.pathname.endsWith('/_search_index.json')) { @@ -315,8 +281,6 @@ test('HTTPEngine.put(model) when the engine fails to put a compiled search index }); }); - const model = getTestModelInstance(valid); - await t.throwsAsync(() => HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', @@ -345,12 +309,7 @@ test('HTTPEngine.put(model) when the engine fails to put a compiled search index 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - }), + body: JSON.stringify(models.getRawSearchIndex(MainModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_search_index.json'), { @@ -359,54 +318,53 @@ test('HTTPEngine.put(model) when the engine fails to put a compiled search index 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/000000000000', [0, 0.288]]], - invertedIndex: [['string', {_index: 0, string: {'MainModel/000000000000': {}}}]], - pipeline: ['stemmer'], - }), + body: JSON.stringify(models.getSearchIndex(MainModel)), }); }); test('HTTPEngine.put(model) when the engine fails to put a raw search index', async t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)], { - 'MainModel/_search_index_raw.json': undefined, + const models = new Models(); + const model = models.createFullTestModel(); + + const fetch = stubFetch({}, Object.values(models.models)); + + fetch.callsFake((url) => { + if (url.pathname.endsWith('/_search_index_raw.json')) { + return Promise.resolve({ + ok: false, + status: 500, + json: async () => { + throw new Error(); + }, + }); + } + + return Promise.resolve({ + ok: true, + status: 200, + json: async () => { + }, + }); }); - const model = getTestModelInstance(valid); - await HTTPEngine.configure({ + await t.throwsAsync(() => HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', fetch, - }).put(model); - - assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/000000000000.json'), { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - method: 'PUT', - body: JSON.stringify(model.toData()), + }).put(model), { + instanceOf: EngineError, + message: 'Failed to put https://example.com/test/MainModel/_search_index_raw.json', }); - assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_index.json'), {headers: {Accept: 'application/json'}}); + t.is(fetch.getCalls().length, 3); - assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_index.json'), { + assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/000000000000.json'), { headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - }), + body: JSON.stringify(model.toData()), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_search_index_raw.json'), {headers: {Accept: 'application/json'}}); @@ -417,26 +375,45 @@ test('HTTPEngine.put(model) when the engine fails to put a raw search index', as 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - }), + body: JSON.stringify(models.getRawSearchIndex(MainModel)), }); }); test('HTTPEngine.put(model) when putting an index fails', async t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)], { - 'MainModel/_index.json': undefined, + const models = new Models(); + const model = models.createFullTestModel(); + + const fetch = stubFetch({}, Object.values(models.models)); + + fetch.callsFake((url) => { + if (url.pathname.endsWith('/_index.json')) { + return Promise.resolve({ + ok: false, + status: 500, + json: async () => { + throw new Error(); + }, + }); + } + + return Promise.resolve({ + ok: true, + status: 200, + json: async () => { + }, + }); }); - const model = getTestModelInstance(valid); - await HTTPEngine.configure({ + await t.throwsAsync(() => HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', fetch, - }).put(model); + }).put(model), { + instanceOf: EngineError, + message: 'Failed to put https://example.com/test/MainModel/_index.json', + }); + + t.is(fetch.getCalls().length, 11); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/000000000000.json'), { headers: { @@ -455,20 +432,15 @@ test('HTTPEngine.put(model) when putting an index fails', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - }), + body: JSON.stringify(models.getIndex(MainModel)), }); }); test('HTTPEngine.put(model) when the initial model put fails', async t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)]); + const models = new Models(); + const model = models.createFullTestModel(); + + const fetch = stubFetch({}, Object.values(models.models)); fetch.callsFake((url) => { if (url.pathname.endsWith('MainModel/000000000000.json')) { @@ -489,7 +461,6 @@ test('HTTPEngine.put(model) when the initial model put fails', async t => { }); }); - const model = getTestModelInstance(valid); await t.throwsAsync(() => HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', @@ -512,7 +483,10 @@ test('HTTPEngine.put(model) when the initial model put fails', async t => { }); test('HTTPEngine.put(model) when the engine fails to put a linked model', async t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)]); + const models = new Models(); + const model = models.createFullTestModel(); + + const fetch = stubFetch({}, Object.values(models.models)); fetch.callsFake((url) => { if (url.pathname.endsWith('LinkedModel/000000000000.json')) { @@ -533,8 +507,6 @@ test('HTTPEngine.put(model) when the engine fails to put a linked model', async }); }); - const model = getTestModelInstance(valid); - await t.throwsAsync(() => HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', @@ -544,7 +516,7 @@ test('HTTPEngine.put(model) when the engine fails to put a linked model', async message: 'Failed to put https://example.com/test/LinkedModel/000000000000.json', }); - t.is(fetch.getCalls().length, 6); + t.is(fetch.getCalls().length, 5); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/000000000000.json'), { headers: { @@ -563,12 +535,7 @@ test('HTTPEngine.put(model) when the engine fails to put a linked model', async 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - }), + body: JSON.stringify(models.getRawSearchIndex(MainModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_search_index.json'), { @@ -577,13 +544,7 @@ test('HTTPEngine.put(model) when the engine fails to put a linked model', async 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/000000000000', [0, 0.288]]], - invertedIndex: [['string', {_index: 0, string: {'MainModel/000000000000': {}}}]], - pipeline: ['stemmer'], - }), + body: JSON.stringify(models.getSearchIndex(MainModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedModel/000000000000.json'), { @@ -594,18 +555,12 @@ test('HTTPEngine.put(model) when the engine fails to put a linked model', async method: 'PUT', body: JSON.stringify(model.linked.toData()), }); - - assertions.calledWith(t, fetch, new URL('https://example.com/test/CircularModel/000000000000.json'), { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - method: 'PUT', - body: JSON.stringify(model.circular.toData()), - }); }); test('HTTPEngine.put(model) updates existing search indexes', async t => { + const models = new Models(); + const model = models.createFullTestModel(); + const fetch = stubFetch({ 'MainModel/_search_index_raw.json': { 'MainModel/111111111111': { @@ -613,9 +568,8 @@ test('HTTPEngine.put(model) updates existing search indexes', async t => { string: 'String', }, }, - }, [getTestModelInstance(valid)]); + }, Object.values(models.models)); - const model = getTestModelInstance(valid); await HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', @@ -630,16 +584,12 @@ test('HTTPEngine.put(model) updates existing search indexes', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ + body: JSON.stringify(models.getRawSearchIndex(MainModel, { 'MainModel/111111111111': { id: 'MainModel/111111111111', string: 'String', }, - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - }), + })), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_search_index.json'), { @@ -648,20 +598,19 @@ test('HTTPEngine.put(model) updates existing search indexes', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/111111111111', [0, 0.182]], ['string/MainModel/000000000000', [0, 0.182]]], - invertedIndex: [['string', { - _index: 0, - string: {'MainModel/111111111111': {}, 'MainModel/000000000000': {}}, - }]], - pipeline: ['stemmer'], - }), + body: JSON.stringify(models.getSearchIndex(MainModel, { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', + string: 'String', + }, + })), }); }); test('HTTPEngine.put(model) updates existing indexes', async t => { + const models = new Models(); + const model = models.createFullTestModel(); + const fetch = stubFetch({ 'MainModel/_index.json': { 'MainModel/111111111111': { @@ -669,9 +618,8 @@ test('HTTPEngine.put(model) updates existing indexes', async t => { string: 'String', }, }, - }, [getTestModelInstance(valid)]); + }, Object.values(models.models)); - const model = getTestModelInstance(valid); await HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', @@ -686,19 +634,12 @@ test('HTTPEngine.put(model) updates existing indexes', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ + body: JSON.stringify(models.getIndex(MainModel, { 'MainModel/111111111111': { id: 'MainModel/111111111111', string: 'String', }, - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - }), + })), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedModel/_index.json'), {headers: {Accept: 'application/json'}}); @@ -709,10 +650,7 @@ test('HTTPEngine.put(model) updates existing indexes', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - }), + body: JSON.stringify(models.getIndex(LinkedModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedManyModel/_index.json'), {headers: {Accept: 'application/json'}}); @@ -723,9 +661,7 @@ test('HTTPEngine.put(model) updates existing indexes', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - }), + body: JSON.stringify(models.getIndex(LinkedManyModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/CircularModel/_index.json'), {headers: {Accept: 'application/json'}}); @@ -736,9 +672,7 @@ test('HTTPEngine.put(model) updates existing indexes', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - }), + body: JSON.stringify(models.getIndex(CircularModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/CircularManyModel/_index.json'), {headers: {Accept: 'application/json'}}); @@ -749,9 +683,7 @@ test('HTTPEngine.put(model) updates existing indexes', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - }), + body: JSON.stringify(models.getIndex(CircularManyModel)), }); assertions.calledWith(t, fetch, new URL('https://example.com/test/_index.json'), {headers: {Accept: 'application/json'}}); @@ -762,52 +694,45 @@ test('HTTPEngine.put(model) updates existing indexes', async t => { 'Content-Type': 'application/json', }, method: 'PUT', - body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - }), + body: JSON.stringify(models.getIndex()), }); }); test('HTTPEngine.find(MainModel, {string: "test"}) when a matching model exists', async t => { - const fetch = stubFetch({}, [getTestModelInstance(valid)]); + const models = new Models(); + const model = models.createFullTestModel(); - const models = await HTTPEngine.configure({ + const fetch = stubFetch({}, Object.values(models.models)); + + const found = await HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', fetch, - }).find(MainModel, {string: 'String'}); + }).find(MainModel, {string: 'test'}); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_index.json'), {headers: {Accept: 'application/json'}}); - t.like(models, [{id: 'MainModel/000000000000', string: 'String'}]); + t.like(found, [model.toIndexData()]); }); -test('HTTPEngine.find(MainModel, {string: "test"}) when a matching model does not exist', async t => { - const fetch = stubFetch({}, [getTestModelInstance({id: 'MainModel/999999999999'})]); +test('HTTPEngine.find(MainModel, {string: "not-even-close-to-a-match"}) when a matching model does not exist', async t => { + const models = new Models(); + models.createFullTestModel(); - const models = await HTTPEngine.configure({ + const fetch = stubFetch({}, Object.values(models.models)); + + const found = await HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', fetch, - }).find(MainModel, {string: 'String'}); + }).find(MainModel, {string: 'not-even-close-to-a-match'}); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_index.json'), {headers: {Accept: 'application/json'}}); - t.deepEqual(models, []); + t.deepEqual(found, []); }); -test('HTTPEngine.find(MainModel, {string: "test"}) when no index exists', async t => { +test('HTTPEngine.find(MainModel, {string: "test"}) when no search index exists', async t => { const fetch = stubFetch({}, []); const models = await HTTPEngine.configure({ @@ -821,51 +746,43 @@ test('HTTPEngine.find(MainModel, {string: "test"}) when no index exists', async t.deepEqual(models, []); }); -test('HTTPEngine.search(MainModel, "Str") when a matching model exists', async t => { - const model0 = getTestModelInstance(valid); - const model1 = getTestModelInstance({ - id: 'MainModel/111111111111', - string: 'another string', - }); - const fetch = stubFetch({}, [model0, model1]); +test('HTTPEngine.search(MainModel, "test") when a matching model exists', async t => { + const models = new Models(); + const model1 = models.createFullTestModel(); + const model2 = models.createFullTestModel(); - const models = await HTTPEngine.configure({ + model2.string = 'moving tests'; + + const fetch = stubFetch({}, Object.values(models.models)); + + const found = await HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', fetch, - }).search(MainModel, 'Str'); + }).search(MainModel, 'test'); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_search_index.json'), {headers: {Accept: 'application/json'}}); assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/000000000000.json'), {headers: {Accept: 'application/json'}}); - assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/111111111111.json'), {headers: {Accept: 'application/json'}}); + assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/000000000001.json'), {headers: {Accept: 'application/json'}}); - t.like(models, [{ + t.like(found, [{ ref: 'MainModel/000000000000', - score: 0.211, - model: { - ...model0.toData(), - date: new Date(model0.date), - requiredDate: new Date(model0.requiredDate), - arrayOfDate: model0.arrayOfDate[0] ? [new Date(model0.arrayOfDate[0])] : [], - requiredArrayOfDate: [new Date(model0.requiredArrayOfDate[0])], - }, + score: 0.666, + model: model1.toData(false), }, { - ref: 'MainModel/111111111111', - score: 0.16, - model: model1.toData(), + ref: 'MainModel/000000000001', + score: 0.506, + model: model2.toData(false), }]); }); test('HTTPEngine.search(MainModel, "not-even-close-to-a-match") when no matching model exists', async t => { - const fetch = stubFetch({}, [ - getTestModelInstance(valid), - getTestModelInstance({ - id: 'MainModel/1111111111111', - string: 'another string', - }), - ]); + const models = new Models(); + models.createFullTestModel(); - const models = await HTTPEngine.configure({ + const fetch = stubFetch({}, Object.values(models.models)); + + const found = await HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', fetch, @@ -873,17 +790,17 @@ test('HTTPEngine.search(MainModel, "not-even-close-to-a-match") when no matching assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/_search_index.json'), {headers: {Accept: 'application/json'}}); - t.deepEqual(models, []); + t.deepEqual(found, []); }); -test('HTTPEngine.search(MainModel, "Str") when no index exists for the model', async t => { +test('HTTPEngine.search(MainModel, "tes") when no index exists for the model', async t => { const fetch = stubFetch({}, []); await t.throwsAsync(async () => await HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', fetch, - }).search(MainModel, 'Str'), { + }).search(MainModel, 'tes'), { instanceOf: EngineError, message: 'The model MainModel does not have a search index available.', }); @@ -892,13 +809,14 @@ test('HTTPEngine.search(MainModel, "Str") when no index exists for the model', a }); test('HTTPEngine.hydrate(model)', async t => { - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); + + const fetch = stubFetch({}, Object.values(models.models)); const dryModel = new MainModel(); dryModel.id = 'MainModel/000000000000'; - const fetch = stubFetch({}, [getTestModelInstance(valid)]); - const hydratedModel = await HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', @@ -908,7 +826,7 @@ test('HTTPEngine.hydrate(model)', async t => { assertions.calledWith(t, fetch, new URL('https://example.com/test/MainModel/000000000000.json'), {headers: {Accept: 'application/json'}}); assertions.calledWith(t, fetch, new URL('https://example.com/test/CircularModel/000000000000.json'), {headers: {Accept: 'application/json'}}); assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedModel/000000000000.json'), {headers: {Accept: 'application/json'}}); - assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedModel/111111111111.json'), {headers: {Accept: 'application/json'}}); + assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedModel/000000000001.json'), {headers: {Accept: 'application/json'}}); assertions.calledWith(t, fetch, new URL('https://example.com/test/LinkedManyModel/000000000000.json'), {headers: {Accept: 'application/json'}}); assertions.calledWith(t, fetch, new URL('https://example.com/test/CircularManyModel/000000000000.json'), {headers: {Accept: 'application/json'}}); diff --git a/src/engine/S3Engine.js b/src/engine/S3Engine.js index 98d9548..e9bc1b9 100644 --- a/src/engine/S3Engine.js +++ b/src/engine/S3Engine.js @@ -5,7 +5,7 @@ class S3EngineError extends EngineError {} class FailedPutS3EngineError extends S3EngineError {} -export default class S3Engine extends Engine { +class S3Engine extends Engine { static checkConfiguration() { if ( !this.configuration?.bucket || @@ -128,3 +128,5 @@ export default class S3Engine extends Engine { } } } + +export default S3Engine; diff --git a/src/engine/S3Engine.test.js b/src/engine/S3Engine.test.js index 3bd3c2c..cd0e6bb 100644 --- a/src/engine/S3Engine.test.js +++ b/src/engine/S3Engine.test.js @@ -1,6 +1,7 @@ +import {CircularManyModel, CircularModel, LinkedManyModel, LinkedModel, MainModel} from '../../test/fixtures/Models.js'; import {EngineError, MissConfiguredError, NotFoundEngineError} from './Engine.js'; import {GetObjectCommand, PutObjectCommand} from '@aws-sdk/client-s3'; -import {MainModel, getTestModelInstance, valid} from '../../test/fixtures/TestModel.js'; +import {Models} from '../../test/fixtures/ModelCollection.js'; import S3Engine from './S3Engine.js'; import assertions from '../../test/assertions.js'; import stubS3Client from '../../test/mocks/s3.js'; @@ -33,10 +34,11 @@ test('S3Engine.get(MainModel, id) when engine is not configured', async t => { }); test('S3Engine.get(MainModel, id) when id exists', async t => { - const client = stubS3Client({ - 'test-bucket': { - 'test/MainModel/000000000000.json': getTestModelInstance(valid).toData(), - }, + const models = new Models(); + models.createFullTestModel(); + + const client = stubS3Client({}, { + 'test-bucket': Object.values(models.models), }); const model = await S3Engine.configure({ @@ -51,11 +53,7 @@ test('S3Engine.get(MainModel, id) when id exists', async t => { })); t.true(model instanceof MainModel); t.true(model.validate()); - t.like(model.toData(), { - ...valid, - stringSlug: 'string', - requiredStringSlug: 'required-string', - }); + t.like(model.toData(), models.models['MainModel/000000000000'].toData()); }); test('S3Engine.get(MainModel, id) when id does not exist', async t => { @@ -77,7 +75,8 @@ test('S3Engine.get(MainModel, id) when id does not exist', async t => { test('S3Engine.put(model)', async t => { const client = stubS3Client(); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.notThrowsAsync(() => S3Engine.configure({ bucket: 'test-bucket', prefix: 'test', @@ -100,15 +99,7 @@ test('S3Engine.put(model)', async t => { Key: 'test/MainModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - }), + Body: JSON.stringify(models.getIndex(MainModel)), })); assertions.calledWith(t, client.send, new GetObjectCommand({ @@ -118,25 +109,14 @@ test('S3Engine.put(model)', async t => { assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index_raw.json', - Body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - }), + Body: JSON.stringify(models.getRawSearchIndex(MainModel)), Bucket: 'test-bucket', ContentType: 'application/json', })); assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index.json', - Body: JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/000000000000', [0, 0.288]]], - invertedIndex: [['string', {_index: 0, string: {'MainModel/000000000000': {}}}]], - pipeline: ['stemmer'], - }), + Body: JSON.stringify(models.getSearchIndex(MainModel)), Bucket: 'test-bucket', ContentType: 'application/json', })); @@ -149,7 +129,7 @@ test('S3Engine.put(model)', async t => { })); assertions.calledWith(t, client.send, new PutObjectCommand({ - Key: 'test/LinkedModel/111111111111.json', + Key: 'test/LinkedModel/000000000001.json', Body: JSON.stringify(model.requiredLinked.toData()), Bucket: 'test-bucket', ContentType: 'application/json', @@ -164,10 +144,7 @@ test('S3Engine.put(model)', async t => { Key: 'test/LinkedModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - }), + Body: JSON.stringify(models.getIndex(LinkedModel)), })); assertions.calledWith(t, client.send, new PutObjectCommand({ @@ -186,9 +163,7 @@ test('S3Engine.put(model)', async t => { Key: 'test/LinkedManyModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - }), + Body: JSON.stringify(models.getIndex(LinkedManyModel)), })); assertions.calledWith(t, client.send, new PutObjectCommand({ @@ -207,9 +182,7 @@ test('S3Engine.put(model)', async t => { Key: 'test/CircularModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - }), + Body: JSON.stringify(models.getIndex(CircularModel)), })); assertions.calledWith(t, client.send, new PutObjectCommand({ @@ -228,9 +201,7 @@ test('S3Engine.put(model)', async t => { Key: 'test/CircularManyModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - }), + Body: JSON.stringify(models.getIndex(CircularManyModel)), })); assertions.calledWith(t, client.send, new GetObjectCommand({ @@ -242,24 +213,14 @@ test('S3Engine.put(model)', async t => { Key: 'test/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - }), + Body: JSON.stringify(models.getIndex()), })); }); test('S3Engine.put(model) updates existing search indexes', async t => { + const models = new Models(); + const model = models.createFullTestModel(); + const client = stubS3Client({ 'test-bucket': { 'MainModel/_search_index_raw.json': { @@ -271,7 +232,6 @@ test('S3Engine.put(model) updates existing search indexes', async t => { }, }); - const model = getTestModelInstance(valid); await t.notThrowsAsync(() => S3Engine.configure({ bucket: 'test-bucket', prefix: 'test', @@ -285,32 +245,27 @@ test('S3Engine.put(model) updates existing search indexes', async t => { assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index_raw.json', - Body: JSON.stringify({ - 'MainModel/111111111111': { - id: 'MainModel/111111111111', - string: 'String', - }, - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', + Body: JSON.stringify(models.getRawSearchIndex( + MainModel, + { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', + string: 'String', + }, }, - }), + )), Bucket: 'test-bucket', ContentType: 'application/json', })); assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index.json', - Body: JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/111111111111', [0, 0.182]], ['string/MainModel/000000000000', [0, 0.182]]], - invertedIndex: [['string', { - _index: 0, - string: {'MainModel/111111111111': {}, 'MainModel/000000000000': {}}, - }]], - pipeline: ['stemmer'], - }), + Body: JSON.stringify(models.getSearchIndex(MainModel, { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', + string: 'String', + }, + })), Bucket: 'test-bucket', ContentType: 'application/json', })); @@ -319,7 +274,13 @@ test('S3Engine.put(model) updates existing search indexes', async t => { test('S3Engine.put(model) updates existing indexes', async t => { const client = stubS3Client({ 'test-bucket': { - 'MainModel/_index.json': { + 'test/MainModel/_index.json': { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', + string: 'String', + }, + }, + 'test/_index.json': { 'MainModel/111111111111': { id: 'MainModel/111111111111', string: 'String', @@ -328,7 +289,8 @@ test('S3Engine.put(model) updates existing indexes', async t => { }, }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.notThrowsAsync(() => S3Engine.configure({ bucket: 'test-bucket', @@ -345,19 +307,15 @@ test('S3Engine.put(model) updates existing indexes', async t => { Key: 'test/MainModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'MainModel/111111111111': { - id: 'MainModel/111111111111', - string: 'String', - }, - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], + Body: JSON.stringify(models.getIndex( + MainModel, + { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', + string: 'String', + }, }, - }), + )), })); assertions.calledWith(t, client.send, new GetObjectCommand({ @@ -369,10 +327,7 @@ test('S3Engine.put(model) updates existing indexes', async t => { Key: 'test/LinkedModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - }), + Body: JSON.stringify(models.getIndex(LinkedModel)), })); assertions.calledWith(t, client.send, new GetObjectCommand({ @@ -384,9 +339,7 @@ test('S3Engine.put(model) updates existing indexes', async t => { Key: 'test/LinkedManyModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - }), + Body: JSON.stringify(models.getIndex(LinkedManyModel)), })); assertions.calledWith(t, client.send, new GetObjectCommand({ @@ -398,9 +351,7 @@ test('S3Engine.put(model) updates existing indexes', async t => { Key: 'test/CircularModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - }), + Body: JSON.stringify(models.getIndex(CircularModel)), })); assertions.calledWith(t, client.send, new GetObjectCommand({ @@ -412,9 +363,7 @@ test('S3Engine.put(model) updates existing indexes', async t => { Key: 'test/CircularManyModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - }), + Body: JSON.stringify(models.getIndex(CircularManyModel)), })); assertions.calledWith(t, client.send, new GetObjectCommand({ @@ -426,20 +375,15 @@ test('S3Engine.put(model) updates existing indexes', async t => { Key: 'test/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], + Body: JSON.stringify(models.getIndex( + undefined, + { + 'MainModel/111111111111': { + id: 'MainModel/111111111111', + string: 'String', + }, }, - 'CircularModel/000000000000': {id: 'CircularModel/000000000000'}, - 'LinkedModel/000000000000': {id: 'LinkedModel/000000000000'}, - 'LinkedModel/111111111111': {id: 'LinkedModel/111111111111'}, - 'CircularManyModel/000000000000': {id: 'CircularManyModel/000000000000'}, - 'LinkedManyModel/000000000000': {id: 'LinkedManyModel/000000000000'}, - }), + )), })); }); @@ -461,7 +405,8 @@ test('S3Engine.put(model) when the engine fails to put a compiled search index', } }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.throwsAsync(() => S3Engine.configure({ bucket: 'test-bucket', @@ -488,25 +433,14 @@ test('S3Engine.put(model) when the engine fails to put a compiled search index', assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index_raw.json', - Body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - }), + Body: JSON.stringify(models.getRawSearchIndex(MainModel)), Bucket: 'test-bucket', ContentType: 'application/json', })); assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index.json', - Body: JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/000000000000', [0, 0.288]]], - invertedIndex: [['string', {_index: 0, string: {'MainModel/000000000000': {}}}]], - pipeline: ['stemmer'], - }), + Body: JSON.stringify(models.getSearchIndex(MainModel)), Bucket: 'test-bucket', ContentType: 'application/json', })); @@ -530,7 +464,8 @@ test('S3Engine.put(model) when the engine fails to put a raw search index', asyn } }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.throwsAsync(() => S3Engine.configure({ bucket: 'test-bucket', @@ -557,12 +492,7 @@ test('S3Engine.put(model) when the engine fails to put a raw search index', asyn assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index_raw.json', - Body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - }), + Body: JSON.stringify(models.getRawSearchIndex(MainModel)), Bucket: 'test-bucket', ContentType: 'application/json', })); @@ -586,7 +516,8 @@ test('S3Engine.put(model) when putting an index fails', async t => { } }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); await t.throwsAsync(() => S3Engine.configure({ bucket: 'test-bucket', @@ -615,15 +546,7 @@ test('S3Engine.put(model) when putting an index fails', async t => { Key: 'test/MainModel/_index.json', Bucket: 'test-bucket', ContentType: 'application/json', - Body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - stringSlug: 'string', - linked: {string: 'test'}, - linkedMany: [{string: 'many'}], - }, - }), + Body: JSON.stringify(models.getIndex(MainModel)), })); assertions.calledWith(t, client.send, new GetObjectCommand({ @@ -633,25 +556,14 @@ test('S3Engine.put(model) when putting an index fails', async t => { assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index_raw.json', - Body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - }), + Body: JSON.stringify(models.getRawSearchIndex(MainModel)), Bucket: 'test-bucket', ContentType: 'application/json', })); assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index.json', - Body: JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/000000000000', [0, 0.288]]], - invertedIndex: [['string', {_index: 0, string: {'MainModel/000000000000': {}}}]], - pipeline: ['stemmer'], - }), + Body: JSON.stringify(models.getSearchIndex(MainModel)), Bucket: 'test-bucket', ContentType: 'application/json', })); @@ -664,7 +576,7 @@ test('S3Engine.put(model) when putting an index fails', async t => { })); assertions.calledWith(t, client.send, new PutObjectCommand({ - Key: 'test/LinkedModel/111111111111.json', + Key: `test/${model.requiredLinked.id}.json`, Body: JSON.stringify(model.requiredLinked.toData()), Bucket: 'test-bucket', ContentType: 'application/json', @@ -703,7 +615,8 @@ test('S3Engine.put(model) when the initial model put fails', async t => { }, }, }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); client.send.callsFake(async command => { if (command.input.Key.endsWith('MainModel/000000000000.json')) { @@ -741,7 +654,8 @@ test('S3Engine.put(model) when the engine fails to put a linked model', async t }, }, }); - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); client.send.callsFake(async command => { if (command.input.Key.endsWith('LinkedModel/000000000000.json')) { @@ -758,7 +672,7 @@ test('S3Engine.put(model) when the engine fails to put a linked model', async t message: 'Failed to put s3://test-bucket/test/LinkedModel/000000000000.json', }); - t.is(client.send.getCalls().length, 6); + t.is(client.send.getCalls().length, 5); assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/000000000000.json', @@ -774,25 +688,14 @@ test('S3Engine.put(model) when the engine fails to put a linked model', async t assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index_raw.json', - Body: JSON.stringify({ - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'String', - }, - }), + Body: JSON.stringify(models.getRawSearchIndex(MainModel)), Bucket: 'test-bucket', ContentType: 'application/json', })); assertions.calledWith(t, client.send, new PutObjectCommand({ Key: 'test/MainModel/_search_index.json', - Body: JSON.stringify({ - version: '2.3.9', - fields: ['string'], - fieldVectors: [['string/MainModel/000000000000', [0, 0.288]]], - invertedIndex: [['string', {_index: 0, string: {'MainModel/000000000000': {}}}]], - pipeline: ['stemmer'], - }), + Body: JSON.stringify(models.getSearchIndex(MainModel)), Bucket: 'test-bucket', ContentType: 'application/json', })); @@ -803,33 +706,23 @@ test('S3Engine.put(model) when the engine fails to put a linked model', async t Bucket: 'test-bucket', ContentType: 'application/json', })); - - assertions.calledWith(t, client.send, new PutObjectCommand({ - Key: 'test/CircularModel/000000000000.json', - Body: JSON.stringify(model.circular.toData()), - Bucket: 'test-bucket', - ContentType: 'application/json', - })); }); test('S3Engine.find(MainModel, {string: "test"}) when a matching model exists', async t => { + const models = new Models(); + const model = models.createFullTestModel(); + const client = stubS3Client({}, { - 'test-bucket': [ - getTestModelInstance(valid), - getTestModelInstance({ - id: 'MainModel/1111111111111', - string: 'another string', - }), - ], + 'test-bucket': Object.values(models.models), }); - const models = await S3Engine.configure({ + const found = await S3Engine.configure({ bucket: 'test-bucket', prefix: 'test', client, - }).find(MainModel, {string: 'String'}); + }).find(MainModel, {string: 'test'}); - t.like(models, [{id: 'MainModel/000000000000', string: 'String'}]); + t.like(found, [model.toIndexData()]); }); test('S3Engine.find(MainModel, {string: "test"}) when a matching model does not exist', async t => { @@ -861,15 +754,15 @@ test('S3Engine.find(MainModel, {string: "test"}) when no index exists', async t t.deepEqual(models, []); }); -test('S3Engine.search(MainModel, "Str") when a matching model exists', async t => { +test('S3Engine.search(MainModel, "test") when matching models exist', async t => { + const models = new Models(); + const model1 = models.createFullTestModel(); + const model2 = models.createFullTestModel(); + + model2.string = 'moving tests'; + const client = stubS3Client({}, { - 'test-bucket': [ - getTestModelInstance(valid), - getTestModelInstance({ - id: 'MainModel/1111111111111', - string: 'another string', - }), - ], + 'test-bucket': Object.values(models.models), }); const configuration = { @@ -878,32 +771,26 @@ test('S3Engine.search(MainModel, "Str") when a matching model exists', async t = client, }; - const model0 = await S3Engine.configure(configuration).get(MainModel, 'MainModel/000000000000'); + const found = await S3Engine.configure(configuration).search(MainModel, 'test'); - const model1 = await S3Engine.configure(configuration).get(MainModel, 'MainModel/1111111111111'); - - const models = await S3Engine.configure(configuration).search(MainModel, 'Str'); - - t.like(models, [{ + t.like(found, [{ ref: 'MainModel/000000000000', - score: 0.211, - model: model0, + score: 0.666, + model: model1.toData(false), }, { - ref: 'MainModel/1111111111111', - score: 0.16, - model: model1, + ref: 'MainModel/000000000001', + score: 0.506, + model: model2.toData(false), }]); }); test('S3Engine.search(MainModel, "not-even-close-to-a-match") when no matching model exists', async t => { + const models = new Models(); + models.createFullTestModel(); + models.createFullTestModel(); + const client = stubS3Client({}, { - 'test-bucket': [ - getTestModelInstance(valid), - getTestModelInstance({ - id: 'MainModel/1111111111111', - string: 'another string', - }), - ], + 'test-bucket': Object.values(models.models), }); const configuration = { @@ -912,12 +799,12 @@ test('S3Engine.search(MainModel, "not-even-close-to-a-match") when no matching m client, }; - const models = await S3Engine.configure(configuration).search(MainModel, 'not-even-close-to-a-match'); + const found = await S3Engine.configure(configuration).search(MainModel, 'not-even-close-to-a-match'); - t.deepEqual(models, []); + t.deepEqual(found, []); }); -test('S3Engine.search(MainModel, "Str") when no index exists for the model', async t => { +test('S3Engine.search(MainModel, "test") when no search index exists for the model', async t => { const client = stubS3Client({}, {}); const configuration = { @@ -926,19 +813,20 @@ test('S3Engine.search(MainModel, "Str") when no index exists for the model', asy client, }; - await t.throwsAsync(async () => await S3Engine.configure(configuration).search(MainModel, 'Str'), { + await t.throwsAsync(async () => await S3Engine.configure(configuration).search(MainModel, 'test'), { instanceOf: EngineError, message: 'The model MainModel does not have a search index available.', }); }); test('S3Engine.hydrate(model)', async t => { - const model = getTestModelInstance(valid); + const models = new Models(); + const model = models.createFullTestModel(); const dryModel = new MainModel(); dryModel.id = 'MainModel/000000000000'; - const client = stubS3Client({}, {'test-bucket': [model]}); + const client = stubS3Client({}, {'test-bucket': Object.values(models.models)}); const hydratedModel = await S3Engine.configure({ bucket: 'test-bucket', @@ -960,7 +848,7 @@ test('S3Engine.hydrate(model)', async t => { })); assertions.calledWith(t, client.send, new GetObjectCommand({ Bucket: 'test-bucket', - Key: 'test/LinkedModel/111111111111.json', + Key: 'test/LinkedModel/000000000001.json', })); assertions.calledWith(t, client.send, new GetObjectCommand({ Bucket: 'test-bucket', diff --git a/src/type/Model.js b/src/type/Model.js index aab1963..1e6337f 100644 --- a/src/type/Model.js +++ b/src/type/Model.js @@ -5,10 +5,32 @@ import {monotonicFactory} from 'ulid'; const createID = monotonicFactory(); -export default class Model { +/** + * @class Model + */ +class Model { + /** + * Represents the model's ID field, defined as a required string. + * + * @type {StringType.required.constructor} + * @static + */ static id = StringType.required; + + /** + * Tracks whether the model is required in a schema. + * + * @type {boolean} + * @static + * @private + */ static _required = false; + /** + * Creates a new instance of the model, initializing properties based on the provided data. + * + * @param {Object} [data={}] - The initial data to populate the model instance. + */ constructor(data = {}) { this.id = `${this.constructor.name}/${createID()}`; @@ -26,7 +48,13 @@ export default class Model { } } - toData() { + /** + * Serializes the model instance into an object, optionally retaining complex types. + * + * @param {boolean} [simple=true] - Determines whether to format the output using only JSON serialisable types. + * @returns {Object} - A serialized representation of the model. + */ + toData(simple = true) { const model = {...this}; for (const [name, property] of Object.entries(this.constructor)) { @@ -35,23 +63,70 @@ export default class Model { } } - return JSON.parse(JSON.stringify(model, (key, value) => { - if (key && this.constructor.isModel(value)) { - return {id: value.id}; - } - return value; - })); + return JSON.parse( + JSON.stringify(model, (key, value) => { + if (key && this.constructor.isModel(value)) { + return {id: value.id}; + } + return value; + }), + (key, value) => { + if (!simple) { + if (this.constructor[key]) { + if (this.constructor[key].name.endsWith('DateType')) { + return new Date(value); + } + + if (this.constructor[key].name.endsWith('ArrayOf(Date)Type')) { + return value.map(d => new Date(d)); + } + } + } + + return value; + }, + ); } + /** + * Validates the current model instance against the defined schema. + * + * @returns {boolean} - Returns `true` if validation succeeds. + * @throws {ValidationError} - Throws this error if validation fails. + */ validate() { return SchemaCompiler.compile(this.constructor).validate(this); } + /** + * Extracts data from the model based on the indexed properties defined in the class. + * + * @returns {Object} - A representation of the model's indexed data. + */ toIndexData() { - const output = { id: this.id }; - const index = this.constructor.indexedProperties(); + return this._extractData(this.constructor.indexedProperties()); + } + + /** + * Extracts data from the model based on the search properties defined in the class. + * + * @returns {Object} - A representation of the model's search data. + */ + toSearchData() { + return this._extractData(this.constructor.searchProperties()); + } - for (const key of index) { + /** + * Extracts specific data fields from the model based on a set of keys. + * + * @param {Array} keys - The keys to extract from the model. + * @returns {Object} - The extracted data. + * @private + */ + _extractData(keys) { + const output = {id: this.id}; + + for (const key of keys) { if (_.has(this, key)) { _.set(output, key, _.get(this, key)); } @@ -75,20 +150,22 @@ export default class Model { return output; } - toSearchData() { - const indexData = {id: this.id}; - - for (const name of this.constructor.searchProperties()) { - indexData[name] = this[name]; - } - - return indexData; - } - + /** + * Returns the name of the model as a string. + * + * @returns {string} - The name of the model class. + * @static + */ static toString() { - return this['name']; + return this.name; } + /** + * Returns a new required version of the current model class. + * + * @returns {this} - A required model subclass. + * @static + */ static get required() { class Required extends this { static _required = true; @@ -99,14 +176,35 @@ export default class Model { return Required; } + /** + * Returns a list of properties that are indexed. + * + * @returns {Array} - The indexed properties. + * @abstract + * @static + */ static indexedProperties() { return []; } + /** + * Returns a list of properties used for search. + * + * @returns {Array} - The search properties. + * @abstract + * @static + */ static searchProperties() { return []; } + /** + * Creates a model instance from raw data. + * + * @param {Object} data - The data to populate the model instance with. + * @returns {Model} - The populated model instance. + * @static + */ static fromData(data) { const model = new this(); @@ -129,6 +227,13 @@ export default class Model { return model; } + /** + * Determines if a given object is a model instance. + * + * @param {Object} possibleModel - The object to check. + * @returns {boolean} - Returns `true` if the object is a model instance. + * @static + */ static isModel(possibleModel) { return ( possibleModel?.prototype instanceof Model || @@ -136,9 +241,17 @@ export default class Model { ); } + /** + * Determines if a given object is a dry model (a simplified object with an ID). + * + * @param {Object} possibleDryModel - The object to check. + * @returns {boolean} - Returns `true` if the object is a valid dry model. + * @static + */ static isDryModel(possibleDryModel) { try { return ( + !this.isModel(possibleDryModel) && Object.keys(possibleDryModel).includes('id') && !!possibleDryModel.id.match(/[A-Za-z]+\/[A-Z0-9]+/) ); @@ -147,3 +260,5 @@ export default class Model { } } } + +export default Model; diff --git a/src/type/Model.test.js b/src/type/Model.test.js index 30b232d..be0d426 100644 --- a/src/type/Model.test.js +++ b/src/type/Model.test.js @@ -1,101 +1,83 @@ -import {MainModel, getTestModelInstance, invalid, valid} from '../../test/fixtures/TestModel.js'; +import {MainModel} from '../../test/fixtures/Models.js'; +import {Models} from '../../test/fixtures/ModelCollection.js'; import Type from './index.js'; import {ValidationError} from '../SchemaCompiler.js'; import test from 'ava'; test('constructor() creates a model instance with an id', t => { - const model = getTestModelInstance(); + const model = new MainModel(); t.true(!!model.id.match(/MainModel\/[A-Z0-9]+/)); }); test('constructor(valid) creates a model using the input valid', t => { - const model = new MainModel(valid); + const model = new MainModel({string: 'String'}); t.true(!!model.id.match(/MainModel\/[A-Z0-9]+/)); - t.like(model.toData(), { - ...valid, - stringSlug: 'string', - requiredStringSlug: 'required-string', - }); + t.like(model.toData(), {string: 'String'}); }); test('model.toData() returns an object representation of the model', t => { - const model = getTestModelInstance(valid); + const data = new Models().createFullTestModel().toData(); - t.deepEqual(model.toData(), { - ...valid, - id: 'MainModel/000000000000', - stringSlug: 'string', - requiredStringSlug: 'required-string', - linked: {id: 'LinkedModel/000000000000'}, - requiredLinked: {id: 'LinkedModel/111111111111'}, - circular: {id: 'CircularModel/000000000000'}, - linkedMany: [{id: 'LinkedManyModel/000000000000'}], - emptyArrayOfModels: [], - circularMany: [{id: 'CircularManyModel/000000000000'}], - }); -}); + delete data.id; -test('model.toIndexData() returns an object with the index properties', t => { - const index = new Type.Model(valid).toIndexData(); + const model = new MainModel(data); - t.assert(index.id.match(/Model\/[A-Z0-9]+/)); + t.like(model.toData(), data); }); -test('testModel.toIndexData() returns an object with the indexed properties', t => { - const index = getTestModelInstance(valid).toIndexData(); +test('model.toIndexData() returns an object with the index properties', t => { + const index = new Models().createFullTestModel().toIndexData(); - t.deepEqual(index, { + t.deepEqual({ + arrayOfString: ['test'], + boolean: false, id: 'MainModel/000000000000', - string: 'String', linked: {string: 'test'}, linkedMany: [{string: 'many'}], - stringSlug: 'string', - }); + number: 24.3, + string: 'test', + stringSlug: 'test', + }, index); }); test('model.toSearchData() returns an object with the searchable properties', t => { - const searchData = new Type.Model(valid).toSearchData(); - - t.assert(searchData.id.match(/Model\/[A-Z0-9]+/)); -}); + const index = new Models().createFullTestModel().toSearchData(); -test('testModel.toSearchData() returns an object with the searchable properties', t => { - const searchData = getTestModelInstance(valid).toSearchData(); - - t.deepEqual(searchData, { + t.deepEqual({ id: 'MainModel/000000000000', - string: 'String', - }); + linked: {string: 'test'}, + linkedMany: [{string: 'many'}], + string: 'test', + stringSlug: 'test', + }, index); }); -test('TestModel.fromData(data) produces a model', t => { - const model = MainModel.fromData(valid); +test('Model.fromData(data) produces a model', t => { + const data = new Models().createFullTestModel().toData(); + const model = MainModel.fromData(data); t.assert(model instanceof MainModel); - t.like(model.toData(), { - ...valid, - stringSlug: 'string', - requiredStringSlug: 'required-string', - }); + t.deepEqual(model.toData(), data); }); test('model.validate() returns true', t => { - const model = getTestModelInstance(valid); + const model = new Models().createFullTestModel(); t.true(model.validate()); }); test('invalidModel.validate() returns true', t => { - const model = getTestModelInstance(invalid); + const model = new Models().createFullTestModel(); + model.string = 123; t.throws(() => model.validate(), {instanceOf: ValidationError}); }); test('Model.isModel(model) returns true', t => { - t.true(Type.Model.isModel(getTestModelInstance())); + t.true(Type.Model.isModel(new Models().createFullTestModel())); }); test('Model.isModel(non-model) returns false', t => { @@ -108,10 +90,14 @@ test('Model.isDryModel(dry-model) returns true', t => { })); }); -test('Model.isDryModel(non-dry-model) returns false', t => { +test('Model.isDryModel(not-a-model) returns false', t => { t.false(Type.Model.isDryModel({})); }); +test('Model.isDryModel(hydrated-model) returns false', t => { + t.false(Type.Model.isDryModel(new Models().createFullTestModel())); +}); + test('Model.isDryModel(almost-dry-model) returns false', t => { t.false(Type.Model.isDryModel({ id: 'NotADryModel/', diff --git a/src/type/Type.js b/src/type/Type.js index 2202b06..117c952 100644 --- a/src/type/Type.js +++ b/src/type/Type.js @@ -1,13 +1,7 @@ /** * @class Type - * @property {string} _type - * @property {boolean} _required - * @property {boolean} _resolved - * @property {map?} _properties - * @property {map?} _items - * @property {map?} _schema */ -export default class Type { +class Type { static _required = false; static _resolved = false; static _properties = undefined; @@ -15,7 +9,7 @@ export default class Type { static _schema = undefined; static toString() { - return this.name?.replace(/Type$/, ''); + return this['name']?.replace(/Type$/, ''); } /** @@ -31,3 +25,5 @@ export default class Type { return Required; } } + +export default Type; diff --git a/src/type/Type.test.js b/src/type/Type.test.js index 1760f77..9c957fa 100644 --- a/src/type/Type.test.js +++ b/src/type/Type.test.js @@ -10,11 +10,11 @@ test('Type is not required', t => { }); test('Type does not have properties', t => { - t.is(Type._properties, undefined); + t.assert(Type._properties === undefined); }); test('Type does not have items', t => { - t.is(Type._items, undefined); + t.assert(Type._items === undefined); }); test('Type is not a resolved type', t => { @@ -30,11 +30,11 @@ test('RequiredType is required', t => { }); test('RequiredType does not have properties', t => { - t.is(Type.required._properties, undefined); + t.assert(Type.required._properties === undefined); }); test('RequiredType does not have items', t => { - t.is(Type.required._items, undefined); + t.assert(Type.required._items === undefined); }); test('RequiredType is not a resolved type', t => { diff --git a/src/type/complex/ArrayType.js b/src/type/complex/ArrayType.js index 00f779f..90b714f 100644 --- a/src/type/complex/ArrayType.js +++ b/src/type/complex/ArrayType.js @@ -1,6 +1,6 @@ import Type from '../Type.js'; -export default class ArrayType { +class ArrayType { static of(type) { class ArrayOf extends Type { static _type = 'array'; @@ -30,3 +30,5 @@ export default class ArrayType { return ArrayOf; } } + +export default ArrayType; diff --git a/src/type/complex/ArrayType.test.js b/src/type/complex/ArrayType.test.js index d80625d..484b67d 100644 --- a/src/type/complex/ArrayType.test.js +++ b/src/type/complex/ArrayType.test.js @@ -1,125 +1,50 @@ import ArrayType from './ArrayType.js'; import BooleanType from '../simple/BooleanType.js'; +import DateType from '../simple/DateType.js'; import NumberType from '../simple/NumberType.js'; import StringType from '../simple/StringType.js'; import test from 'ava'; -test('ArrayType.of(StringType) is ArrayOf(String)', t => { - t.is(ArrayType.of(StringType).toString(), 'ArrayOf(String)'); -}); +const typesToTest = [StringType, NumberType, BooleanType, DateType]; -test('ArrayType.of(StringType) is not required', t => { - t.is(ArrayType.of(StringType)._required, false); -}); +for (const type of typesToTest) { + test(`ArrayType.of(${type}) is ArrayOf(${type})`, t => { + t.is(ArrayType.of(type).toString(), `ArrayOf(${type})`); + }); -test('ArrayType.of(StringType) does not have properties', t => { - t.is(ArrayType.of(StringType)._properties, undefined); -}); + test(`ArrayType.of(${type}) is not required`, t => { + t.is(ArrayType.of(type)._required, false); + }); -test('ArrayType.of(StringType) has items of type String', t => { - t.is(ArrayType.of(StringType)._items, StringType); -}); + test(`ArrayType.of(${type}) does not have properties`, t => { + t.assert(ArrayType.of(type)._properties === undefined); + }); -test('ArrayType.of(StringType) is not a resolved type', t => { - t.is(ArrayType.of(StringType)._resolved, false); -}); + test(`ArrayType.of(${type}) has items of type String`, t => { + t.is(ArrayType.of(type)._items, type); + }); -test('RequiredArrayType.of(StringType) is RequiredArrayOf(String)', t => { - t.is(ArrayType.of(StringType).required.toString(), 'RequiredArrayOf(String)'); -}); + test(`ArrayType.of(${type}) is not a resolved type`, t => { + t.is(ArrayType.of(type)._resolved, false); + }); -test('RequiredArrayType.of(StringType) is required', t => { - t.is(ArrayType.of(StringType).required._required, true); -}); + test(`RequiredArrayType.of(${type}) is RequiredArrayOf(${type})`, t => { + t.is(ArrayType.of(type).required.toString(), `RequiredArrayOf(${type})`); + }); -test('RequiredArrayType.of(StringType) does not have properties', t => { - t.is(ArrayType.of(StringType).required._properties, undefined); -}); + test(`RequiredArrayType.of(${type}) is required`, t => { + t.is(ArrayType.of(type).required._required, true); + }); -test('RequiredArrayType.of(StringType) has items of type String', t => { - t.is(ArrayType.of(StringType).required._items, StringType); -}); + test(`RequiredArrayType.of(${type}) does not have properties`, t => { + t.assert(ArrayType.of(type).required._properties === undefined); + }); -test('RequiredArrayType.of(StringType) is not a resolved type', t => { - t.is(ArrayType.of(StringType).required._resolved, false); -}); + test(`RequiredArrayType.of(${type}) has items of type String`, t => { + t.is(ArrayType.of(type).required._items, type); + }); -test('ArrayType.of(NumberType) is ArrayOf(Number)', t => { - t.is(ArrayType.of(NumberType).toString(), 'ArrayOf(Number)'); -}); - -test('ArrayType.of(NumberType) is not required', t => { - t.is(ArrayType.of(NumberType)._required, false); -}); - -test('ArrayType.of(NumberType) does not have properties', t => { - t.is(ArrayType.of(NumberType)._properties, undefined); -}); - -test('ArrayType.of(NumberType) has items of type Number', t => { - t.is(ArrayType.of(NumberType)._items, NumberType); -}); - -test('ArrayType.of(NumberType) is not a resolved type', t => { - t.is(ArrayType.of(NumberType)._resolved, false); -}); - -test('RequiredArrayType.of(NumberType) is RequiredArrayOf(Number)', t => { - t.is(ArrayType.of(NumberType).required.toString(), 'RequiredArrayOf(Number)'); -}); - -test('RequiredArrayType.of(NumberType) is required', t => { - t.is(ArrayType.of(NumberType).required._required, true); -}); - -test('RequiredArrayType.of(NumberType) does not have properties', t => { - t.is(ArrayType.of(NumberType).required._properties, undefined); -}); - -test('RequiredArrayType.of(NumberType) has items of type Number', t => { - t.is(ArrayType.of(NumberType).required._items, NumberType); -}); - -test('RequiredArrayType.of(NumberType) is not a resolved type', t => { - t.is(ArrayType.of(NumberType).required._resolved, false); -}); - -test('ArrayType.of(BooleanType) is ArrayOf(Boolean)', t => { - t.is(ArrayType.of(BooleanType).toString(), 'ArrayOf(Boolean)'); -}); - -test('ArrayType.of(BooleanType) is not required', t => { - t.is(ArrayType.of(BooleanType)._required, false); -}); - -test('ArrayType.of(BooleanType) does not have properties', t => { - t.is(ArrayType.of(BooleanType)._properties, undefined); -}); - -test('ArrayType.of(BooleanType) has items of type Boolean', t => { - t.is(ArrayType.of(BooleanType)._items, BooleanType); -}); - -test('ArrayType.of(BooleanType) is not a resolved type', t => { - t.is(ArrayType.of(BooleanType)._resolved, false); -}); - -test('RequiredArrayType.of(BooleanType) is RequiredArrayOf(Boolean)', t => { - t.is(ArrayType.of(BooleanType).required.toString(), 'RequiredArrayOf(Boolean)'); -}); - -test('RequiredArrayType.of(BooleanType) is required', t => { - t.is(ArrayType.of(BooleanType).required._required, true); -}); - -test('RequiredArrayType.of(BooleanType) does not have properties', t => { - t.is(ArrayType.of(BooleanType).required._properties, undefined); -}); - -test('RequiredArrayType.of(BooleanType) has items of type Boolean', t => { - t.is(ArrayType.of(BooleanType).required._items, BooleanType); -}); - -test('RequiredArrayType.of(BooleanType) is not a resolved type', t => { - t.is(ArrayType.of(BooleanType).required._resolved, false); -}); + test(`RequiredArrayType.of(${type}) is not a resolved type`, t => { + t.is(ArrayType.of(type).required._resolved, false); + }); +} diff --git a/src/type/complex/CustomType.js b/src/type/complex/CustomType.js index f0935b2..a0626a2 100644 --- a/src/type/complex/CustomType.js +++ b/src/type/complex/CustomType.js @@ -1,7 +1,7 @@ import Type from '../Type.js'; import ajv from 'ajv'; -export default class CustomType { +class CustomType { static of(schema) { new ajv().compile(schema); @@ -13,3 +13,5 @@ export default class CustomType { return Custom; } } + +export default CustomType; diff --git a/src/type/complex/CustomType.test.js b/src/type/complex/CustomType.test.js index b060dd3..e4018f5 100644 --- a/src/type/complex/CustomType.test.js +++ b/src/type/complex/CustomType.test.js @@ -20,11 +20,11 @@ test('CustomType.of(validSchema) is not required', t => { }); test('CustomType.of(validSchema) does not have properties', t => { - t.is(CustomType.of(validSchema)._properties, undefined); + t.assert(CustomType.of(validSchema)._properties === undefined); }); test('CustomType.of(validSchema) has items of type String', t => { - t.is(CustomType.of(validSchema)._items, undefined); + t.assert(CustomType.of(validSchema)._items === undefined); }); test('CustomType.of(validSchema) is not a resolved type', t => { @@ -40,11 +40,11 @@ test('RequiredCustomType.of(validSchema) is required', t => { }); test('RequiredCustomType.of(validSchema) does not have properties', t => { - t.is(CustomType.of(validSchema).required._properties, undefined); + t.assert(CustomType.of(validSchema).required._properties === undefined); }); test('RequiredCustomType.of(validSchema) has items of type String', t => { - t.is(CustomType.of(validSchema).required._items, undefined); + t.assert(CustomType.of(validSchema).required._items === undefined); }); test('RequiredCustomType.of(validSchema) is not a resolved type', t => { diff --git a/src/type/resolved/ResolvedType.js b/src/type/resolved/ResolvedType.js index 48a2ff7..e642a7b 100644 --- a/src/type/resolved/ResolvedType.js +++ b/src/type/resolved/ResolvedType.js @@ -1,6 +1,6 @@ import Type from '../Type.js'; -export default class ResolvedType extends Type { +class ResolvedType extends Type { static _resolved = true; static resolve(_model) { @@ -17,3 +17,5 @@ export default class ResolvedType extends Type { return ResolvedTypeOf; } } + +export default ResolvedType; diff --git a/src/type/resolved/ResolvedType.test.js b/src/type/resolved/ResolvedType.test.js index 63ac784..e0a579d 100644 --- a/src/type/resolved/ResolvedType.test.js +++ b/src/type/resolved/ResolvedType.test.js @@ -18,11 +18,11 @@ test('UnimplementedResolvedType is not required', t => { }); test('UnimplementedResolvedType does not have properties', t => { - t.is(UnimplementedResolvedType._properties, undefined); + t.assert(UnimplementedResolvedType._properties === undefined); }); test('UnimplementedResolvedType does not have items', t => { - t.is(UnimplementedResolvedType._items, undefined); + t.assert(UnimplementedResolvedType._items === undefined); }); test('UnimplementedResolvedType is a resolved type', t => { diff --git a/src/type/resolved/SlugType.js b/src/type/resolved/SlugType.js index e068837..cb00d99 100644 --- a/src/type/resolved/SlugType.js +++ b/src/type/resolved/SlugType.js @@ -1,7 +1,7 @@ import ResolvedType from './ResolvedType.js'; import slugify from 'slugify'; -export default class SlugType extends ResolvedType { +class SlugType extends ResolvedType { static of(property) { class SlugOf extends ResolvedType { static _type = 'string'; @@ -22,3 +22,5 @@ export default class SlugType extends ResolvedType { return SlugOf; } } + +export default SlugType; diff --git a/src/type/resolved/SlugType.test.js b/src/type/resolved/SlugType.test.js index b3daff2..15f97d9 100644 --- a/src/type/resolved/SlugType.test.js +++ b/src/type/resolved/SlugType.test.js @@ -14,11 +14,11 @@ test('SlugType is not required', t => { }); test('SlugType does not have properties', t => { - t.is(SlugType._properties, undefined); + t.assert(SlugType._properties === undefined); }); test('SlugType does not have items', t => { - t.is(SlugType._items, undefined); + t.assert(SlugType._items === undefined); }); test('SlugType is a resolved type', t => { diff --git a/src/type/simple/BooleanType.js b/src/type/simple/BooleanType.js index 40b54d5..b775be1 100644 --- a/src/type/simple/BooleanType.js +++ b/src/type/simple/BooleanType.js @@ -1,5 +1,7 @@ import SimpleType from './SimpleType.js'; -export default class BooleanType extends SimpleType { +class BooleanType extends SimpleType { static _type = 'boolean'; } + +export default BooleanType; diff --git a/src/type/simple/BooleanType.test.js b/src/type/simple/BooleanType.test.js index 70fd082..7032a62 100644 --- a/src/type/simple/BooleanType.test.js +++ b/src/type/simple/BooleanType.test.js @@ -10,11 +10,11 @@ test('BooleanType is not required', t => { }); test('BooleanType does not have properties', t => { - t.is(BooleanType._properties, undefined); + t.assert(BooleanType._properties === undefined); }); test('BooleanType does not have items', t => { - t.is(BooleanType._items, undefined); + t.assert(BooleanType._items === undefined); }); test('BooleanType is not a resolved type', t => { @@ -30,11 +30,11 @@ test('RequiredBooleanType is required', t => { }); test('RequiredBooleanType does not have properties', t => { - t.is(BooleanType.required._properties, undefined); + t.assert(BooleanType.required._properties === undefined); }); test('RequiredBooleanType does not have items', t => { - t.is(BooleanType.required._items, undefined); + t.assert(BooleanType.required._items === undefined); }); test('RequiredBooleanType is not a resolved type', t => { diff --git a/src/type/simple/DateType.js b/src/type/simple/DateType.js index d004e92..6c06123 100644 --- a/src/type/simple/DateType.js +++ b/src/type/simple/DateType.js @@ -1,10 +1,17 @@ import SimpleType from './SimpleType.js'; -export default class DateType extends SimpleType { +class DateType extends SimpleType { static _type = 'string'; static _format = 'iso-date-time'; + /** + * + * @param {Date|number|string} possibleDate + * @return {boolean} + */ static isDate(possibleDate) { return possibleDate instanceof Date || !isNaN(new Date(possibleDate)); } } + +export default DateType; diff --git a/src/type/simple/DateType.test.js b/src/type/simple/DateType.test.js index e8175c7..241fa12 100644 --- a/src/type/simple/DateType.test.js +++ b/src/type/simple/DateType.test.js @@ -5,16 +5,28 @@ test('DateType is Date', t => { t.is(DateType.toString(), 'Date'); }); +test('DateType.isDate(not-a-date) returns false', t => { + t.false(DateType.isDate('not-a-date')); +}); + +test('DateType.isDate(date) returns true', t => { + t.true(DateType.isDate(new Date())); +}); + +test('DateType.isDate(date-string) returns true', t => { + t.true(DateType.isDate('2024-09-21T09:25:34.595Z')); +}); + test('DateType is not required', t => { t.is(DateType._required, false); }); test('DateType does not have properties', t => { - t.is(DateType._properties, undefined); + t.assert(DateType._properties === undefined); }); test('DateType does not have items', t => { - t.is(DateType._items, undefined); + t.assert(DateType._items === undefined); }); test('DateType is not a resolved type', t => { @@ -30,11 +42,11 @@ test('RequiredDateType is required', t => { }); test('RequiredDateType does not have properties', t => { - t.is(DateType.required._properties, undefined); + t.assert(DateType.required._properties === undefined); }); test('RequiredDateType does not have items', t => { - t.is(DateType.required._items, undefined); + t.assert(DateType.required._items === undefined); }); test('RequiredDateType is not a resolved type', t => { diff --git a/src/type/simple/NumberType.js b/src/type/simple/NumberType.js index 5e018e9..06ee1dd 100644 --- a/src/type/simple/NumberType.js +++ b/src/type/simple/NumberType.js @@ -1,5 +1,7 @@ import SimpleType from './SimpleType.js'; -export default class NumberType extends SimpleType { +class NumberType extends SimpleType { static _type = 'number'; } + +export default NumberType; diff --git a/src/type/simple/NumberType.test.js b/src/type/simple/NumberType.test.js index c5d392b..ee10718 100644 --- a/src/type/simple/NumberType.test.js +++ b/src/type/simple/NumberType.test.js @@ -10,11 +10,11 @@ test('NumberType is not required', t => { }); test('NumberType does not have properties', t => { - t.is(NumberType._properties, undefined); + t.assert(NumberType._properties === undefined); }); test('NumberType does not have items', t => { - t.is(NumberType._items, undefined); + t.assert(NumberType._items === undefined); }); test('NumberType is not a resolved type', t => { @@ -30,11 +30,11 @@ test('RequiredNumberType is required', t => { }); test('RequiredNumberType does not have properties', t => { - t.is(NumberType.required._properties, undefined); + t.assert(NumberType.required._properties === undefined); }); test('RequiredNumberType does not have items', t => { - t.is(NumberType.required._items, undefined); + t.assert(NumberType.required._items === undefined); }); test('RequiredNumberType is not a resolved type', t => { diff --git a/src/type/simple/SimpleType.js b/src/type/simple/SimpleType.js index 5018b2a..dd182ea 100644 --- a/src/type/simple/SimpleType.js +++ b/src/type/simple/SimpleType.js @@ -1,4 +1,6 @@ import Type from '../Type.js'; -export default class SimpleType extends Type { +class SimpleType extends Type { } + +export default SimpleType; diff --git a/src/type/simple/StringType.js b/src/type/simple/StringType.js index bd92248..dd889fb 100644 --- a/src/type/simple/StringType.js +++ b/src/type/simple/StringType.js @@ -1,5 +1,7 @@ import SimpleType from './SimpleType.js'; -export default class StringType extends SimpleType { +class StringType extends SimpleType { static _type = 'string'; } + +export default StringType; diff --git a/src/type/simple/StringType.test.js b/src/type/simple/StringType.test.js index b106e96..fbbe5ac 100644 --- a/src/type/simple/StringType.test.js +++ b/src/type/simple/StringType.test.js @@ -10,11 +10,11 @@ test('StringType is not required', t => { }); test('StringType does not have properties', t => { - t.is(StringType._properties, undefined); + t.assert(StringType._properties === undefined); }); test('StringType does not have items', t => { - t.is(StringType._items, undefined); + t.assert(StringType._items === undefined); }); test('StringType is not a resolved type', t => { @@ -30,11 +30,11 @@ test('RequiredStringType is required', t => { }); test('RequiredStringType does not have properties', t => { - t.is(StringType.required._properties, undefined); + t.assert(StringType.required._properties === undefined); }); test('RequiredStringType does not have items', t => { - t.is(StringType.required._items, undefined); + t.assert(StringType.required._items === undefined); }); test('RequiredStringType is not a resolved type', t => { diff --git a/test/fixtures/ModelCollection.js b/test/fixtures/ModelCollection.js new file mode 100644 index 0000000..857e2ac --- /dev/null +++ b/test/fixtures/ModelCollection.js @@ -0,0 +1,136 @@ +import {CircularManyModel, CircularModel, LinkedManyModel, LinkedModel, MainModel} from './Models.js'; +import lunr from 'lunr'; + +export class Models { + constructor() { + this.models = {}; + } + + addModel(model) { + this.models[model.id] = model; + } + + getIndex(model = undefined, additionalIndexData = {}) { + if (model) { + return Object.fromEntries( + Object.entries(additionalIndexData) + .concat( + Object.entries(this.models) + .filter(([id, _]) => id.startsWith(`${model.name}/`)) + .map(([_, model]) => [model.id, model.toIndexData()]) + .concat(Object.entries(additionalIndexData)), + ), + ); + } + + return Object.fromEntries( + Object.entries(additionalIndexData) + .concat( + Object.entries(this.models) + .map(([_, model]) => [model.id, model.toIndexData()]) + .concat(Object.entries(additionalIndexData)), + ), + ); + } + + getRawSearchIndex(model, additionalSearchData = {}) { + return Object.fromEntries( + Object.entries(additionalSearchData) + .concat( + Object.entries(this.models) + .filter(([id, _]) => id.startsWith(`${model.name}/`)) + .map(([_, model]) => [model.id, model.toSearchData()]), + ), + ); + } + + getSearchIndex(model, additionalSearchData = {}) { + const rawSearchIndex = this.getRawSearchIndex(model); + return lunr(function () { + this.ref('id'); + + for (const field of model.searchProperties()) { + this.field(field); + } + + Object.values({ + ...additionalSearchData, + ...rawSearchIndex, + }).forEach(function (doc) { + this.add(doc); + }, this); + }); + } + + getNumericId(modelInstance) { + return parseInt(modelInstance.id.replace(`${modelInstance.constructor.name}/`, '')); + } + + getNextModelId(modelInstance) { + const lastId = Object.values(this.models) + .filter(m => m.id.startsWith(`${modelInstance.constructor.name}/`)) + .map(m => this.getNumericId(m)) + .toSorted((a, b) => a - b) + .pop(); + + return `${modelInstance.constructor.name}/${(lastId + 1 || 0).toString(10).padStart(12, '0')}`; + } + + createFullTestModel(override = {}) { + const defaults = { + string: 'test', + requiredString: 'required test', + number: 24.3, + requiredNumber: 12.2, + boolean: false, + requiredBoolean: true, + date: new Date(), + requiredDate: new Date(), + emptyArrayOfStrings: [], + emptyArrayOfNumbers: [], + emptyArrayOfBooleans: [], + emptyArrayOfDates: [], + arrayOfString: ['test'], + arrayOfNumber: [24.5], + arrayOfBoolean: [false], + arrayOfDate: [new Date()], + requiredArrayOfString: ['test'], + requiredArrayOfNumber: [24.5], + requiredArrayOfBoolean: [false], + requiredArrayOfDate: [new Date()], + }; + + const model = new MainModel({...defaults, ...override}); + model.id = this.getNextModelId(model); + this.addModel(model); + + const linked = new LinkedModel({string: 'test'}); + linked.id = this.getNextModelId(linked); + model.linked = linked; + this.addModel(linked); + + const requiredLinked = new LinkedModel({string: 'test'}); + requiredLinked.id = this.getNextModelId(requiredLinked); + model.requiredLinked = requiredLinked; + this.addModel(requiredLinked); + + + const circular = new CircularModel({linked: model}); + circular.id = this.getNextModelId(circular); + model.circular = circular; + this.addModel(circular); + + const circularMany = new CircularManyModel({linked: [model]}); + circularMany.id = this.getNextModelId(circularMany); + model.circularMany = [circularMany]; + this.addModel(circularMany); + + + const linkedMany = new LinkedManyModel({string: 'many'}); + linkedMany.id = this.getNextModelId(linkedMany); + model.linkedMany = [linkedMany]; + this.addModel(linkedMany); + + return model; + } +} diff --git a/test/fixtures/Models.js b/test/fixtures/Models.js new file mode 100644 index 0000000..29fb8e9 --- /dev/null +++ b/test/fixtures/Models.js @@ -0,0 +1,64 @@ +import Type from '../../src/type/index.js'; + +export class LinkedModel extends Type.Model { + static string = Type.String; +} + +export class LinkedManyModel extends Type.Model { + static string = Type.String; +} + +export class CircularModel extends Type.Model { + static linked = () => MainModel; +} + +export class CircularManyModel extends Type.Model { + static linked = () => Type.Array.of(MainModel); +} + +export class MainModel extends Type.Model { + static custom = Type.Custom.of({ + type: 'object', + additionalProperties: false, + properties: {test: {type: 'string'}}, + required: ['test'], + }); + static string = Type.String; + static stringSlug = Type.Resolved.Slug.of('string'); + static requiredString = Type.String.required; + static requiredStringSlug = Type.Resolved.Slug.of('requiredString'); + static number = Type.Number; + static requiredNumber = Type.Number.required; + static boolean = Type.Boolean; + static requiredBoolean = Type.Boolean.required; + static date = Type.Date; + static requiredDate = Type.Date.required; + static emptyArrayOfStrings = Type.Array.of(Type.String); + static emptyArrayOfNumbers = Type.Array.of(Type.Number); + static emptyArrayOfBooleans = Type.Array.of(Type.Boolean); + static emptyArrayOfDates = Type.Array.of(Type.Date); + static emptyArrayOfModels = Type.Array.of(LinkedManyModel); + static arrayOfString = Type.Array.of(Type.String); + static arrayOfNumber = Type.Array.of(Type.Number); + static arrayOfBoolean = Type.Array.of(Type.Boolean); + static arrayOfDate = Type.Array.of(Type.Date); + static requiredArrayOfString = Type.Array.of(Type.String).required; + static requiredArrayOfNumber = Type.Array.of(Type.Number).required; + static requiredArrayOfBoolean = Type.Array.of(Type.Boolean).required; + static requiredArrayOfDate = Type.Array.of(Type.Date).required; + static circular = CircularModel; + static circularMany = Type.Array.of(CircularManyModel); + static linked = () => LinkedModel; + static requiredLinked = LinkedModel.required; + static linkedMany = () => Type.Array.of(LinkedManyModel); + static indexedProperties = () => [ + 'string', + 'boolean', + 'number', + 'arrayOfString', + 'stringSlug', + 'linked.string', + 'linkedMany.[*].string', + ]; + static searchProperties = () => ['string', 'stringSlug', 'linked.string', 'linkedMany.[*].string']; +} diff --git a/test/fixtures/TestIndex.js b/test/fixtures/TestIndex.js deleted file mode 100644 index 93a0b09..0000000 --- a/test/fixtures/TestIndex.js +++ /dev/null @@ -1,20 +0,0 @@ -export const TestIndex = { - 'MainModel/000000000000': { - id: 'MainModel/000000000000', - string: 'test', - arrayOfString: ['test'], - linkedMany: [{ - id: 'LinkedManyModel/000000000000000', - string: 'test', - }], - }, - 'MainModel/111111111111': { - id: 'MainModel/111111111111', - string: 'testing', - arrayOfString: ['testing'], - linkedMany: [{ - id: 'LinkedManyModel/111111111111', - string: 'testing', - }], - }, -}; diff --git a/test/fixtures/TestModel.js b/test/fixtures/TestModel.js deleted file mode 100644 index 7a849a1..0000000 --- a/test/fixtures/TestModel.js +++ /dev/null @@ -1,196 +0,0 @@ -import DateType from '../../src/type/simple/DateType.js'; -import Type from '../../src/type/index.js'; - -export const valid = { - string: 'String', - requiredString: 'Required String', - number: 24.3, - requiredNumber: 12.2, - boolean: false, - requiredBoolean: true, - date: new Date().toISOString(), - requiredDate: new Date().toISOString(), - emptyArrayOfStrings: [], - emptyArrayOfNumbers: [], - emptyArrayOfBooleans: [], - emptyArrayOfDates: [], - arrayOfString: ['String'], - arrayOfNumber: [24.5], - arrayOfBoolean: [false], - arrayOfDate: [new Date().toISOString()], - requiredArrayOfString: ['String'], - requiredArrayOfNumber: [24.5], - requiredArrayOfBoolean: [false], - requiredArrayOfDate: [new Date().toISOString()], -}; - -export const invalid = { - string: false, - requiredString: undefined, - number: 'test', - requiredNumber: undefined, - boolean: 13.4, - requiredBoolean: undefined, - date: 'not-a-date', - requiredDate: undefined, - emptyArrayOfStrings: 'not-a-list', - emptyArrayOfNumbers: 'not-a-list', - emptyArrayOfBooleans: 'not-a-list', - emptyArrayOfDates: 'not-a-list', - arrayOfString: [true], - arrayOfNumber: ['string'], - arrayOfBoolean: [15.8], - arrayOfDate: ['not-a-date'], - requiredArrayOfString: [true], - requiredArrayOfNumber: ['string'], - requiredArrayOfBoolean: [15.8], - requiredArrayOfDate: ['not-a-date'], -}; - -/** - * @class LinkedModel - * @extends Type.Model - * @property {string} string - */ -export class LinkedModel extends Type.Model { - static string = Type.String; -} - -/** - * @class LinkedManyModel - * @extends Type.Model - * @property {string} string - */ -export class LinkedManyModel extends Type.Model { - static string = Type.String; -} - -/** - * @class MainModel - * @extends Type.Model - * @property {object} custom - * @property {string?} string - * @property {string?} stringSlug - * @property {string} requiredString - * @property {string} requiredStringSlug - * @property {number?} number - * @property {number} requiredNumber - * @property {boolean?} boolean - * @property {boolean} requiredBoolean - * @property {string[]?} arrayOfString - * @property {number[]?} arrayOfNumber - * @property {boolean[]?} arrayOfBoolean - * @property {string[]} requiredArrayOfString - * @property {number[]} requiredArrayOfNumber - * @property {boolean[]} requiredArrayOfBoolean - * @property {CircularModel} circular - * @property {LinkedModel} linked - * @property {CircularManyModel[]} circularMany - * @property {LinkedManyModel[]} linkedMany - */ -export class MainModel extends Type.Model { - static custom = Type.Custom.of({ - type: 'object', - additionalProperties: false, - properties: {test: {type: 'string'}}, - required: ['test'], - }); - static string = Type.String; - static stringSlug = Type.Resolved.Slug.of('string'); - static requiredString = Type.String.required; - static requiredStringSlug = Type.Resolved.Slug.of('requiredString'); - static number = Type.Number; - static requiredNumber = Type.Number.required; - static boolean = Type.Boolean; - static requiredBoolean = Type.Boolean.required; - static date = Type.Date; - static requiredDate = Type.Date.required; - static emptyArrayOfStrings = Type.Array.of(Type.String); - static emptyArrayOfNumbers = Type.Array.of(Type.Number); - static emptyArrayOfBooleans = Type.Array.of(Type.Boolean); - static emptyArrayOfDates = Type.Array.of(Type.Date); - static emptyArrayOfModels = () => Type.Array.of(LinkedManyModel); - static arrayOfString = Type.Array.of(Type.String); - static arrayOfNumber = Type.Array.of(Type.Number); - static arrayOfBoolean = Type.Array.of(Type.Boolean); - static arrayOfDate = Type.Array.of(Type.Date); - static requiredArrayOfString = Type.Array.of(Type.String).required; - static requiredArrayOfNumber = Type.Array.of(Type.Number).required; - static requiredArrayOfBoolean = Type.Array.of(Type.Boolean).required; - static requiredArrayOfDate = Type.Array.of(Type.Date).required; - static circular = () => CircularModel; - static circularMany = () => Type.Array.of(CircularManyModel); - static linked = LinkedModel; - static requiredLinked = LinkedModel.required; - static linkedMany = Type.Array.of(LinkedManyModel); - static indexedProperties = () => ['string', 'stringSlug', 'linked.string', 'linkedMany.[*].string']; - static searchProperties = () => ['string']; -} - -/** - * @class CircularModel - * @extends Type.Model - * @property {MainModel} linked - */ -export class CircularModel extends Type.Model { - static linked = MainModel; -} - -/** - * @class CircularManyModel - * @extends Type.Model - * @property {MainModel[]} linked - */ -export class CircularManyModel extends Type.Model { - static linked = Type.Array.of(MainModel); -} - -export function getTestModelInstance(data = {}) { - const model = new MainModel(data); - if (!data.id) { - model.id = model.id.replace(/[a-zA-Z0-9]+$/, '000000000000'); - } else { - model.id = data.id; - } - - if (DateType.isDate(data.date)) model.date = new Date(data.date); - if (DateType.isDate(data.requiredDate)) model.requiredDate = new Date(data.requiredDate); - if (data.arrayOfDate) model.arrayOfDate = data.arrayOfDate.map(d => DateType.isDate(d) ? new Date(d) : d); - if (data.requiredArrayOfDate) model.requiredArrayOfDate = data.requiredArrayOfDate.map(d => DateType.isDate(d) ? new Date(d) : d); - - if (!model.emptyArrayOfStrings) model.emptyArrayOfStrings = []; - if (!model.emptyArrayOfNumbers) model.emptyArrayOfNumbers = []; - if (!model.emptyArrayOfBooleans) model.emptyArrayOfBooleans = []; - if (!model.emptyArrayOfDates) model.emptyArrayOfDates = []; - if (!model.emptyArrayOfModels) model.emptyArrayOfModels = []; - - const circular = new CircularModel({linked: model}); - circular.id = circular.id.replace(/[a-zA-Z0-9]+$/, '000000000000'); - model.circular = circular; - - const linked = new LinkedModel({string: 'test'}); - linked.id = linked.id.replace(/[a-zA-Z0-9]+$/, '000000000000'); - model.linked = linked; - - const requiredLinked = new LinkedModel({string: 'test'}); - requiredLinked.id = requiredLinked.id.replace(/[a-zA-Z0-9]+$/, '111111111111'); - model.requiredLinked = requiredLinked; - - const circularMany = new CircularManyModel({linked: [model]}); - circularMany.id = circularMany.id.replace(/[a-zA-Z0-9]+$/, '000000000000'); - model.circularMany = [circularMany]; - - const linkedMany = new LinkedManyModel({string: 'many'}); - linkedMany.id = linkedMany.id.replace(/[a-zA-Z0-9]+$/, '000000000000'); - model.linkedMany = [linkedMany]; - - if (JSON.stringify(data) === JSON.stringify(invalid)) { - model.id = model.id.replace(/[a-zA-Z0-9]+$/, 'Not A Valid ID'); - circular.id = circular.id.replace(/[a-zA-Z0-9]+$/, 'Not A Valid ID'); - linked.id = linked.id.replace(/[a-zA-Z0-9]+$/, 'Not A Valid ID'); - circularMany.id = circularMany.id.replace(/[a-zA-Z0-9]+$/, 'Not A Valid ID'); - linkedMany.id = linkedMany.id.replace(/[a-zA-Z0-9]+$/, 'Not A Valid ID'); - } - - return model; -} diff --git a/test/mocks/fs.js b/test/mocks/fs.js index affe6a5..8e3e820 100644 --- a/test/mocks/fs.js +++ b/test/mocks/fs.js @@ -52,7 +52,7 @@ function stubFs(filesystem = {}, models = []) { if (searchIndexes.length > 0) { for (const [name, index] of searchIndexes) { const fields = [...new Set(Object.values(index).map(i => Object.keys(i).filter(i => i !== 'id')).flat(Infinity))]; - const compiledIndex = lunr(function () { + resolvedFiles[name.replace('_raw', '')] = lunr(function () { this.ref('id'); for (const field of fields) { @@ -63,8 +63,6 @@ function stubFs(filesystem = {}, models = []) { this.add(doc); }, this); }); - - resolvedFiles[name.replace('_raw', '')] = compiledIndex; } } diff --git a/test/mocks/s3.js b/test/mocks/s3.js index 3a7adeb..b365f3c 100644 --- a/test/mocks/s3.js +++ b/test/mocks/s3.js @@ -65,7 +65,7 @@ function stubS3Client(filesystem = {}, models = {}) { if (searchIndexes.length > 0) { for (const [name, index] of searchIndexes) { const fields = [...new Set(Object.values(index).map(i => Object.keys(i).filter(i => i !== 'id')).flat(Infinity))]; - const compiledIndex = lunr(function () { + resolvedFiles[name.replace('_raw', '')] = lunr(function () { this.ref('id'); for (const field of fields) { @@ -76,8 +76,6 @@ function stubS3Client(filesystem = {}, models = {}) { this.add(doc); }, this); }); - - resolvedFiles[name.replace('_raw', '')] = compiledIndex; } } From 66b10e14bb2707ced7426a9606fb765f18beb08d Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 14:14:16 +0100 Subject: [PATCH 05/20] refactor: unnecessary return await --- src/Transactions.test.js | 8 ++++---- src/engine/Engine.api.test.js | 12 ++++++------ src/engine/FileEngine.test.js | 4 ++-- src/engine/HTTPEngine.test.js | 4 ++-- src/engine/S3Engine.test.js | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Transactions.test.js b/src/Transactions.test.js index e66c7a0..e484f1d 100644 --- a/src/Transactions.test.js +++ b/src/Transactions.test.js @@ -47,7 +47,7 @@ test('transaction.commit() throws an exception if the transaction was successful await transaction.commit(); - await t.throwsAsync(async () => await transaction.commit(), { + await t.throwsAsync(() => transaction.commit(), { instanceOf: TransactionCommittedError, message: 'Transaction was already committed.', }); @@ -68,7 +68,7 @@ test('transaction.commit() throws an exception if the transaction fails', async await transaction.put(model); await t.throwsAsync( - async () => await transaction.commit(), + () => transaction.commit(), { instanceOf: EngineError, message: 'Failed to put model', @@ -98,7 +98,7 @@ test('transaction.commit() reverts already commited changes if the transaction f await transaction.put(model); await t.throwsAsync( - async () => await transaction.commit(), + () => transaction.commit(), { instanceOf: EngineError, message: 'Failed to put model LinkedModel/000000000000', @@ -132,7 +132,7 @@ test('transaction.commit() reverts already commited changes if the transaction f await transaction.put(model); await t.throwsAsync( - async () => await transaction.commit(), + () => transaction.commit(), { instanceOf: EngineError, message: 'Failed to put model LinkedModel/000000000000', diff --git a/src/engine/Engine.api.test.js b/src/engine/Engine.api.test.js index 47b68ce..f8f7f3b 100644 --- a/src/engine/Engine.api.test.js +++ b/src/engine/Engine.api.test.js @@ -64,7 +64,7 @@ for (const {engine, configuration, configurationIgnores} of engines) { test(`${engine.toString()}.get(MainModel, id) throws MissConfiguredError when engine is not configured`, async t => { const error = await t.throwsAsync( - async () => await engine.get(MainModel, 'MainModel/000000000000'), + () => engine.get(MainModel, 'MainModel/000000000000'), { instanceOf: MissConfiguredError, }, @@ -91,7 +91,7 @@ for (const {engine, configuration, configurationIgnores} of engines) { const store = engine.configure(configuration()); const error = await t.throwsAsync( - async () => await store.get(MainModel, 'MainModel/999999999999'), + () => store.get(MainModel, 'MainModel/999999999999'), { instanceOf: NotFoundEngineError, }, @@ -102,7 +102,7 @@ for (const {engine, configuration, configurationIgnores} of engines) { test(`${engine.toString()}.put(model) throws MissConfiguredError when engine is not configured`, async t => { const error = await t.throwsAsync( - async () => await engine.put(MainModel, {string: 'string'}), + () => engine.put(MainModel, {string: 'string'}), { instanceOf: MissConfiguredError, }, @@ -120,7 +120,7 @@ for (const {engine, configuration, configurationIgnores} of engines) { test(`${engine.toString()}.find(MainModel, parameters) throws MissConfiguredError when engine is not configured`, async t => { const error = await t.throwsAsync( - async () => await engine.find(MainModel, {string: 'string'}), + () => engine.find(MainModel, {string: 'string'}), { instanceOf: MissConfiguredError, }, @@ -139,7 +139,7 @@ for (const {engine, configuration, configurationIgnores} of engines) { test(`${engine.toString()}.search(MainModel, 'string') throws MissConfiguredError when engine is not configured`, async t => { const error = await t.throwsAsync( - async () => await engine.search(MainModel, 'string'), + () => engine.search(MainModel, 'string'), { instanceOf: MissConfiguredError, }, @@ -162,7 +162,7 @@ for (const {engine, configuration, configurationIgnores} of engines) { test(`${engine.toString()}.hydrate(model) throws MissConfiguredError when engine is not configured`, async t => { const error = await t.throwsAsync( - async () => await engine.hydrate(new Models().createFullTestModel().toData()), + () => engine.hydrate(new Models().createFullTestModel().toData()), { instanceOf: MissConfiguredError, }, diff --git a/src/engine/FileEngine.test.js b/src/engine/FileEngine.test.js index 380c3e1..16d4e9c 100644 --- a/src/engine/FileEngine.test.js +++ b/src/engine/FileEngine.test.js @@ -17,7 +17,7 @@ test('FileEngine.configure(configuration) returns a new engine without altering test('FileEngine.get(MainModel, id) when engine is not configured', async t => { const error = await t.throwsAsync( - async () => await FileEngine.get(MainModel, 'MainModel/000000000000'), + () => FileEngine.get(MainModel, 'MainModel/000000000000'), { instanceOf: MissConfiguredError, }, @@ -425,7 +425,7 @@ test('FileEngine.search(MainModel, "test") when no index exists for the model', filesystem, }; - await t.throwsAsync(async () => await FileEngine.configure(configuration).search(MainModel, 'test'), { + await t.throwsAsync(() => FileEngine.configure(configuration).search(MainModel, 'test'), { instanceOf: EngineError, message: 'The model MainModel does not have a search index available.', }); diff --git a/src/engine/HTTPEngine.test.js b/src/engine/HTTPEngine.test.js index 69e122b..1167bf7 100644 --- a/src/engine/HTTPEngine.test.js +++ b/src/engine/HTTPEngine.test.js @@ -53,7 +53,7 @@ test('HTTPEngine.configure(configuration) with additional headers returns a new test('HTTPEngine.get(MainModel, id) when engine is not configured', async t => { const error = await t.throwsAsync( - async () => await HTTPEngine.get(MainModel, 'MainModel/000000000000'), + () => HTTPEngine.get(MainModel, 'MainModel/000000000000'), { instanceOf: MissConfiguredError, }, @@ -796,7 +796,7 @@ test('HTTPEngine.search(MainModel, "not-even-close-to-a-match") when no matching test('HTTPEngine.search(MainModel, "tes") when no index exists for the model', async t => { const fetch = stubFetch({}, []); - await t.throwsAsync(async () => await HTTPEngine.configure({ + await t.throwsAsync(() => HTTPEngine.configure({ host: 'https://example.com', prefix: 'test', fetch, diff --git a/src/engine/S3Engine.test.js b/src/engine/S3Engine.test.js index cd0e6bb..d2e5e19 100644 --- a/src/engine/S3Engine.test.js +++ b/src/engine/S3Engine.test.js @@ -24,7 +24,7 @@ test('S3Engine.configure(configuration) returns a new engine without altering th test('S3Engine.get(MainModel, id) when engine is not configured', async t => { const error = await t.throwsAsync( - async () => await S3Engine.get(MainModel, 'MainModel/000000000000'), + () => S3Engine.get(MainModel, 'MainModel/000000000000'), { instanceOf: MissConfiguredError, }, @@ -813,7 +813,7 @@ test('S3Engine.search(MainModel, "test") when no search index exists for the mod client, }; - await t.throwsAsync(async () => await S3Engine.configure(configuration).search(MainModel, 'test'), { + await t.throwsAsync(() => S3Engine.configure(configuration).search(MainModel, 'test'), { instanceOf: EngineError, message: 'The model MainModel does not have a search index available.', }); From cff112b5b761ee7d1ef720ec9f85731d3fdd2804 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 14:15:55 +0100 Subject: [PATCH 06/20] refactor: avoid square-bracket notation --- src/engine/Engine.js | 20 ++++++++++---------- src/type/Type.js | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/engine/Engine.js b/src/engine/Engine.js index 0b21bcb..913a85f 100644 --- a/src/engine/Engine.js +++ b/src/engine/Engine.js @@ -19,7 +19,7 @@ class Engine { * @abstract */ static async getById(_id) { - throw new NotImplementedError(`${this['name']} must implement .getById()`); + throw new NotImplementedError(`${this.name} must implement .getById()`); } /** @@ -30,7 +30,7 @@ class Engine { * @abstract */ static async putModel(_data) { - throw new NotImplementedError(`${this['name']} must implement .putModel()`); + throw new NotImplementedError(`${this.name} must implement .putModel()`); } /** @@ -41,7 +41,7 @@ class Engine { * @abstract */ static async getIndex(_model) { - throw new NotImplementedError(`${this['name']} does not implement .getIndex()`); + throw new NotImplementedError(`${this.name} does not implement .getIndex()`); } /** @@ -52,7 +52,7 @@ class Engine { * @abstract */ static async putIndex(_index) { - throw new NotImplementedError(`${this['name']} does not implement .putIndex()`); + throw new NotImplementedError(`${this.name} does not implement .putIndex()`); } /** @@ -63,7 +63,7 @@ class Engine { * @abstract */ static async getSearchIndexCompiled(_model) { - throw new NotImplementedError(`${this['name']} does not implement .getSearchIndexCompiled()`); + throw new NotImplementedError(`${this.name} does not implement .getSearchIndexCompiled()`); } /** @@ -74,7 +74,7 @@ class Engine { * @abstract */ static async getSearchIndexRaw(_model) { - throw new NotImplementedError(`${this['name']} does not implement .getSearchIndexRaw()`); + throw new NotImplementedError(`${this.name} does not implement .getSearchIndexRaw()`); } /** @@ -86,7 +86,7 @@ class Engine { * @abstract */ static async putSearchIndexCompiled(_model, _compiledIndex) { - throw new NotImplementedError(`${this['name']} does not implement .putSearchIndexCompiled()`); + throw new NotImplementedError(`${this.name} does not implement .putSearchIndexCompiled()`); } /** @@ -98,7 +98,7 @@ class Engine { * @abstract */ static async putSearchIndexRaw(_model, _rawIndex) { - throw new NotImplementedError(`${this['name']} does not implement .putSearchIndexRaw()`); + throw new NotImplementedError(`${this.name} does not implement .putSearchIndexRaw()`); } /** @@ -200,7 +200,7 @@ class Engine { return model.fromData(found); } catch (error) { if (error.constructor === NotImplementedError) throw error; - throw new NotFoundEngineError(`${this['name']}.get(${id}) model not found`, error); + throw new NotFoundEngineError(`${this.name}.get(${id}) model not found`, error); } } @@ -285,7 +285,7 @@ class Engine { } static toString() { - return this['name']; + return this.name; } } diff --git a/src/type/Type.js b/src/type/Type.js index 117c952..f906607 100644 --- a/src/type/Type.js +++ b/src/type/Type.js @@ -9,7 +9,7 @@ class Type { static _schema = undefined; static toString() { - return this['name']?.replace(/Type$/, ''); + return this.name?.replace(/Type$/, ''); } /** From b75d39e77a7658bed0f5e71f54fef5047ec2144d Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 14:19:08 +0100 Subject: [PATCH 07/20] refactor: empty functions --- src/engine/HTTPEngine.test.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/engine/HTTPEngine.test.js b/src/engine/HTTPEngine.test.js index 1167bf7..e2336a3 100644 --- a/src/engine/HTTPEngine.test.js +++ b/src/engine/HTTPEngine.test.js @@ -276,8 +276,7 @@ test('HTTPEngine.put(model) when the engine fails to put a compiled search index return Promise.resolve({ ok: true, status: 200, - json: async () => { - }, + json: async () => ({}), }); }); @@ -342,8 +341,7 @@ test('HTTPEngine.put(model) when the engine fails to put a raw search index', as return Promise.resolve({ ok: true, status: 200, - json: async () => { - }, + json: async () => ({}), }); }); @@ -399,8 +397,7 @@ test('HTTPEngine.put(model) when putting an index fails', async t => { return Promise.resolve({ ok: true, status: 200, - json: async () => { - }, + json: async () => ({}), }); }); @@ -456,8 +453,7 @@ test('HTTPEngine.put(model) when the initial model put fails', async t => { return Promise.resolve({ ok: true, status: 200, - json: async () => { - }, + json: async () => ({}), }); }); @@ -502,8 +498,7 @@ test('HTTPEngine.put(model) when the engine fails to put a linked model', async return Promise.resolve({ ok: true, status: 200, - json: async () => { - }, + json: async () => ({}), }); }); From 3a8aa4144d9238d6a978745ccde8ec5be5fa8dc3 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 15:14:34 +0100 Subject: [PATCH 08/20] refactor: variable name shadows variable in outer scope --- src/engine/Engine.js | 26 +++++++++++++------------- test/fixtures/ModelCollection.js | 6 +++--- test/mocks/fs.js | 4 ++-- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/engine/Engine.js b/src/engine/Engine.js index 913a85f..649e0c9 100644 --- a/src/engine/Engine.js +++ b/src/engine/Engine.js @@ -144,27 +144,27 @@ class Engine { const uploadedModels = []; const indexUpdates = {}; - const processModel = async (model) => { - if (uploadedModels.includes(model.id)) return false; - model.validate(); + const processModel = async (m) => { + if (uploadedModels.includes(m.id)) return false; + m.validate(); - await this.putModel(model); + await this.putModel(m); - uploadedModels.push(model.id); - indexUpdates[model.constructor.name] = (indexUpdates[model.constructor.name] ?? []).concat([model]); + uploadedModels.push(m.id); + indexUpdates[m.constructor.name] = (indexUpdates[m.constructor.name] ?? []).concat([m]); - if (model.constructor.searchProperties().length > 0) { + if (m.constructor.searchProperties().length > 0) { const rawSearchIndex = { - ...await this.getSearchIndexRaw(model.constructor), - [model.id]: model.toSearchData(), + ...await this.getSearchIndexRaw(m.constructor), + [m.id]: m.toSearchData(), }; - await this.putSearchIndexRaw(model.constructor, rawSearchIndex); + await this.putSearchIndexRaw(m.constructor, rawSearchIndex); const compiledIndex = lunr(function () { this.ref('id'); - for (const field of model.constructor.searchProperties()) { + for (const field of m.constructor.searchProperties()) { this.field(field); } @@ -173,10 +173,10 @@ class Engine { }, this); }); - await this.putSearchIndexCompiled(model.constructor, compiledIndex); + await this.putSearchIndexCompiled(m.constructor, compiledIndex); } - for (const [_, property] of Object.entries(model)) { + for (const [_, property] of Object.entries(m)) { if (Type.Model.isModel(property)) { await processModel(property); } diff --git a/test/fixtures/ModelCollection.js b/test/fixtures/ModelCollection.js index 857e2ac..1ddbf90 100644 --- a/test/fixtures/ModelCollection.js +++ b/test/fixtures/ModelCollection.js @@ -17,7 +17,7 @@ export class Models { .concat( Object.entries(this.models) .filter(([id, _]) => id.startsWith(`${model.name}/`)) - .map(([_, model]) => [model.id, model.toIndexData()]) + .map(([id, m]) => [id, m.toIndexData()]) .concat(Object.entries(additionalIndexData)), ), ); @@ -27,7 +27,7 @@ export class Models { Object.entries(additionalIndexData) .concat( Object.entries(this.models) - .map(([_, model]) => [model.id, model.toIndexData()]) + .map(([id, m]) => [id, m.toIndexData()]) .concat(Object.entries(additionalIndexData)), ), ); @@ -39,7 +39,7 @@ export class Models { .concat( Object.entries(this.models) .filter(([id, _]) => id.startsWith(`${model.name}/`)) - .map(([_, model]) => [model.id, model.toSearchData()]), + .map(([id, m]) => [id, m.toSearchData()]), ), ); } diff --git a/test/mocks/fs.js b/test/mocks/fs.js index 8e3e820..d1c5aeb 100644 --- a/test/mocks/fs.js +++ b/test/mocks/fs.js @@ -5,8 +5,8 @@ import sinon from 'sinon'; function stubFs(filesystem = {}, models = []) { const modelsAddedToFilesystem = []; - function fileSystemFromModels(initialFilesystem = {}, ...models) { - for (const model of models) { + function fileSystemFromModels(initialFilesystem = {}, ...initialModels) { + for (const model of initialModels) { const modelIndexPath = model.id.replace(/[A-Z0-9]+$/, '_index.json'); const searchIndexRawPath = model.id.replace(/[A-Z0-9]+$/, '_search_index_raw.json'); From 866776be5874afab2a8cdc1abd80a5606304772a Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 15:17:07 +0100 Subject: [PATCH 09/20] refactor: class methods should utilize this --- test/fixtures/ModelCollection.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/fixtures/ModelCollection.js b/test/fixtures/ModelCollection.js index 1ddbf90..a9e8850 100644 --- a/test/fixtures/ModelCollection.js +++ b/test/fixtures/ModelCollection.js @@ -62,14 +62,14 @@ export class Models { }); } - getNumericId(modelInstance) { + static getNumericId(modelInstance) { return parseInt(modelInstance.id.replace(`${modelInstance.constructor.name}/`, '')); } getNextModelId(modelInstance) { const lastId = Object.values(this.models) .filter(m => m.id.startsWith(`${modelInstance.constructor.name}/`)) - .map(m => this.getNumericId(m)) + .map(m => Models.getNumericId(m)) .toSorted((a, b) => a - b) .pop(); From 5b343f68f36a51a3223ce9fc9c2b7e409ff153ce Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 15:21:08 +0100 Subject: [PATCH 10/20] refactor: all code paths should have explicit returns, or none --- src/engine/Engine.js | 59 ++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/src/engine/Engine.js b/src/engine/Engine.js index 649e0c9..3285b3a 100644 --- a/src/engine/Engine.js +++ b/src/engine/Engine.js @@ -145,44 +145,45 @@ class Engine { const indexUpdates = {}; const processModel = async (m) => { - if (uploadedModels.includes(m.id)) return false; - m.validate(); + if (!uploadedModels.includes(m.id)) { + m.validate(); - await this.putModel(m); + await this.putModel(m); - uploadedModels.push(m.id); - indexUpdates[m.constructor.name] = (indexUpdates[m.constructor.name] ?? []).concat([m]); + uploadedModels.push(m.id); + indexUpdates[m.constructor.name] = (indexUpdates[m.constructor.name] ?? []).concat([m]); - if (m.constructor.searchProperties().length > 0) { - const rawSearchIndex = { - ...await this.getSearchIndexRaw(m.constructor), - [m.id]: m.toSearchData(), - }; + if (m.constructor.searchProperties().length > 0) { + const rawSearchIndex = { + ...await this.getSearchIndexRaw(m.constructor), + [m.id]: m.toSearchData(), + }; - await this.putSearchIndexRaw(m.constructor, rawSearchIndex); + await this.putSearchIndexRaw(m.constructor, rawSearchIndex); - const compiledIndex = lunr(function () { - this.ref('id'); + const compiledIndex = lunr(function () { + this.ref('id'); - for (const field of m.constructor.searchProperties()) { - this.field(field); - } - - Object.values(rawSearchIndex).forEach(function (doc) { - this.add(doc); - }, this); - }); + for (const field of m.constructor.searchProperties()) { + this.field(field); + } - await this.putSearchIndexCompiled(m.constructor, compiledIndex); - } + Object.values(rawSearchIndex).forEach(function (doc) { + this.add(doc); + }, this); + }); - for (const [_, property] of Object.entries(m)) { - if (Type.Model.isModel(property)) { - await processModel(property); + await this.putSearchIndexCompiled(m.constructor, compiledIndex); } - if (Array.isArray(property) && Type.Model.isModel(property[0])) { - for (const subModel of property) { - await processModel(subModel); + + for (const [_, property] of Object.entries(m)) { + if (Type.Model.isModel(property)) { + await processModel(property); + } + if (Array.isArray(property) && Type.Model.isModel(property[0])) { + for (const subModel of property) { + await processModel(subModel); + } } } } From 3fcadbcb2137ce5e6ec2f2e0f5cc6cded5c4dbe6 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 15:23:30 +0100 Subject: [PATCH 11/20] refactor: remove shorthand type coercions --- src/engine/HTTPEngine.js | 2 +- src/engine/S3Engine.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/engine/HTTPEngine.js b/src/engine/HTTPEngine.js index 7f6e452..e3c1d58 100644 --- a/src/engine/HTTPEngine.js +++ b/src/engine/HTTPEngine.js @@ -115,7 +115,7 @@ class HTTPEngine extends Engine { } static async getIndex(location) { - const url = new URL([this.configuration.host, this.configuration.prefix, location, '_index.json'].filter(e => !!e).join('/')); + const url = new URL([this.configuration.host, this.configuration.prefix, location, '_index.json'].filter(e => Boolean(e)).join('/')); return await this._processFetch(url, this._getReadOptions(), {}); } diff --git a/src/engine/S3Engine.js b/src/engine/S3Engine.js index e9bc1b9..90b9055 100644 --- a/src/engine/S3Engine.js +++ b/src/engine/S3Engine.js @@ -42,7 +42,7 @@ class S3Engine extends Engine { static async getIndex(location) { try { const data = await this.configuration.client.send(new GetObjectCommand({ - Key: [this.configuration.prefix, location, '_index.json'].filter(e => !!e).join('/'), + Key: [this.configuration.prefix, location, '_index.json'].filter(e => Boolean(e)).join('/'), Bucket: this.configuration.bucket, })); @@ -55,7 +55,7 @@ class S3Engine extends Engine { static async putIndex(index) { const processIndex = async (location, models) => { const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()])); - const Key = [this.configuration.prefix, location, '_index.json'].filter(e => !!e).join('/'); + const Key = [this.configuration.prefix, location, '_index.json'].filter(e => Boolean(e)).join('/'); const currentIndex = await this.getIndex(location); From 646154cb917a2bad0eaca44f751f739576b6f060 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 15:25:25 +0100 Subject: [PATCH 12/20] refactor: usage of exported name as property of default import --- test/assertions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/assertions.js b/test/assertions.js index 6254f95..1b1726b 100644 --- a/test/assertions.js +++ b/test/assertions.js @@ -12,7 +12,7 @@ function parseArgument(arg) { } } -export function calledWith(t, spy, ...args) { +function calledWith(t, spy, ...args) { for (const call of spy.getCalls()) { const calledArguments = call.args.map(parseArgument); const expectedArguments = args.map(parseArgument); From c9f9aa1a3fbdefa4f39fe7ac79604f33314064cb Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 15:31:15 +0100 Subject: [PATCH 13/20] docs: add jsdoc blocks for test models --- test/fixtures/Models.js | 78 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/test/fixtures/Models.js b/test/fixtures/Models.js index 29fb8e9..661bc3e 100644 --- a/test/fixtures/Models.js +++ b/test/fixtures/Models.js @@ -1,21 +1,87 @@ import Type from '../../src/type/index.js'; +/** + * Represents a model with a linked string property. + * + * @class LinkedModel + * @extends {Type.Model} + * @property {Type.String} string - A string type property. + */ export class LinkedModel extends Type.Model { static string = Type.String; } +/** + * Represents a model with an array of linked string properties. + * + * @class LinkedManyModel + * @extends {Type.Model} + * @property {Type.String} string - A string type property. + */ export class LinkedManyModel extends Type.Model { static string = Type.String; } +/** + * Represents a model with a circular reference to the `MainModel`. + * + * @class CircularModel + * @extends {Type.Model} + * @property {MainModel} linked - A circular reference to the `MainModel`. + */ export class CircularModel extends Type.Model { static linked = () => MainModel; } +/** + * Represents a model with an array of circular references to the `MainModel`. + * + * @class CircularManyModel + * @extends {Type.Model} + * @property {MainModel[]} linked - An array of circular references to the `MainModel`. + */ export class CircularManyModel extends Type.Model { static linked = () => Type.Array.of(MainModel); } +/** + * Represents the main model with various properties including strings, numbers, booleans, dates, arrays, + * and linked models. + * + * @class MainModel + * @extends {Type.Model} + * @property {Type.Custom} custom - A custom object with validation rules. + * @property {Type.String} string - A simple string property. + * @property {Type.Resolved.Slug} stringSlug - A resolved slug based on a string. + * @property {Type.String} requiredString - A required string property. + * @property {Type.Resolved.Slug} requiredStringSlug - A resolved slug based on a required string. + * @property {Type.Number} number - A number property. + * @property {Type.Number} requiredNumber - A required number property. + * @property {Type.Boolean} boolean - A boolean property. + * @property {Type.Boolean} requiredBoolean - A required boolean property. + * @property {Type.Date} date - A date property. + * @property {Type.Date} requiredDate - A required date property. + * @property {Type.Array} emptyArrayOfStrings - An empty array of strings. + * @property {Type.Array} emptyArrayOfNumbers - An empty array of numbers. + * @property {Type.Array} emptyArrayOfBooleans - An empty array of booleans. + * @property {Type.Array} emptyArrayOfDates - An empty array of dates. + * @property {Type.Array} emptyArrayOfModels - An empty array of linked models. + * @property {Type.Array} arrayOfString - An array of strings. + * @property {Type.Array} arrayOfNumber - An array of numbers. + * @property {Type.Array} arrayOfBoolean - An array of booleans. + * @property {Type.Array} arrayOfDate - An array of dates. + * @property {Type.Array} requiredArrayOfString - A required array of strings. + * @property {Type.Array} requiredArrayOfNumber - A required array of numbers. + * @property {Type.Array} requiredArrayOfBoolean - A required array of booleans. + * @property {Type.Array} requiredArrayOfDate - A required array of dates. + * @property {CircularModel} circular - A circular reference to the `CircularModel`. + * @property {Type.Array} circularMany - An array of circular references to the `CircularManyModel`. + * @property {LinkedModel} linked - A reference to a `LinkedModel`. + * @property {LinkedModel} requiredLinked - A required reference to a `LinkedModel`. + * @property {Type.Array} linkedMany - An array of references to `LinkedManyModel`. + * @method indexedProperties Returns the list of properties to be indexed. + * @method searchProperties Returns the list of properties used in search. + */ export class MainModel extends Type.Model { static custom = Type.Custom.of({ type: 'object', @@ -51,6 +117,12 @@ export class MainModel extends Type.Model { static linked = () => LinkedModel; static requiredLinked = LinkedModel.required; static linkedMany = () => Type.Array.of(LinkedManyModel); + + /** + * Returns the list of properties to be indexed. + * + * @returns {string[]} An array of property names to be indexed. + */ static indexedProperties = () => [ 'string', 'boolean', @@ -60,5 +132,11 @@ export class MainModel extends Type.Model { 'linked.string', 'linkedMany.[*].string', ]; + + /** + * Returns the list of properties used in search. + * + * @returns {string[]} An array of property names used for search. + */ static searchProperties = () => ['string', 'stringSlug', 'linked.string', 'linkedMany.[*].string']; } From 38ee35f96ab320243ba506ca78df2456ab5dd248 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 15:35:57 +0100 Subject: [PATCH 14/20] docs: add jsdoc block for test model collection --- test/fixtures/ModelCollection.js | 59 ++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/test/fixtures/ModelCollection.js b/test/fixtures/ModelCollection.js index a9e8850..3068953 100644 --- a/test/fixtures/ModelCollection.js +++ b/test/fixtures/ModelCollection.js @@ -1,15 +1,40 @@ import {CircularManyModel, CircularModel, LinkedManyModel, LinkedModel, MainModel} from './Models.js'; import lunr from 'lunr'; +/** + * Class representing a collection of models with methods to manipulate and retrieve model data. + * + * @class Models + */ export class Models { + /** + * Creates an instance of Models. + * + * @constructor + */ constructor() { + /** + * @property {Object} models - A dictionary of models indexed by their ID. + */ this.models = {}; } + /** + * Adds a model to the collection. + * + * @param {Object} model - The model instance to add. Must have an `id` property. + */ addModel(model) { this.models[model.id] = model; } + /** + * Retrieves an index of models, optionally filtered by a specific model type and including additional index data. + * + * @param {Object} [model] - The model class to filter the index by (optional). + * @param {Object} [additionalIndexData={}] - Additional index data to include in the result. + * @returns {Object} An index of models, with model IDs as keys and index data as values. + */ getIndex(model = undefined, additionalIndexData = {}) { if (model) { return Object.fromEntries( @@ -33,6 +58,13 @@ export class Models { ); } + /** + * Retrieves the raw search index for models filtered by a specific model type and additional search data. + * + * @param {Object} model - The model class to filter the index by. + * @param {Object} [additionalSearchData={}] - Additional search data to include in the result. + * @returns {Object} The raw search index with model IDs as keys and search data as values. + */ getRawSearchIndex(model, additionalSearchData = {}) { return Object.fromEntries( Object.entries(additionalSearchData) @@ -44,6 +76,13 @@ export class Models { ); } + /** + * Generates a Lunr search index for a specific model class, including additional search data. + * + * @param {Object} model - The model class to generate the search index for. + * @param {Object} [additionalSearchData={}] - Additional search data to include in the index. + * @returns {lunr.Index} A compiled Lunr search index. + */ getSearchIndex(model, additionalSearchData = {}) { const rawSearchIndex = this.getRawSearchIndex(model); return lunr(function () { @@ -62,10 +101,22 @@ export class Models { }); } + /** + * Extracts the numeric portion of a model's ID. + * + * @param {Object} modelInstance - The model instance whose ID to extract. + * @returns {number} The numeric ID. + */ static getNumericId(modelInstance) { return parseInt(modelInstance.id.replace(`${modelInstance.constructor.name}/`, '')); } + /** + * Generates the next model ID based on the existing models of the same type. + * + * @param {Object} modelInstance - The model instance to generate an ID for. + * @returns {string} The next available model ID in the form `${ModelName}/`. + */ getNextModelId(modelInstance) { const lastId = Object.values(this.models) .filter(m => m.id.startsWith(`${modelInstance.constructor.name}/`)) @@ -76,6 +127,12 @@ export class Models { return `${modelInstance.constructor.name}/${(lastId + 1 || 0).toString(10).padStart(12, '0')}`; } + /** + * Creates and adds a new instance of `MainModel` with pre-defined properties, optionally overriding the default values. + * + * @param {Object} [override={}] - An object containing properties to override the defaults. + * @returns {MainModel} A new `MainModel` instance with linked, circular, and linkedMany models. + */ createFullTestModel(override = {}) { const defaults = { string: 'test', @@ -114,7 +171,6 @@ export class Models { model.requiredLinked = requiredLinked; this.addModel(requiredLinked); - const circular = new CircularModel({linked: model}); circular.id = this.getNextModelId(circular); model.circular = circular; @@ -125,7 +181,6 @@ export class Models { model.circularMany = [circularMany]; this.addModel(circularMany); - const linkedMany = new LinkedManyModel({string: 'many'}); linkedMany.id = this.getNextModelId(linkedMany); model.linkedMany = [linkedMany]; From f3824fb297f45d04028291621e5f709aa6b05850 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Mon, 23 Sep 2024 15:42:55 +0100 Subject: [PATCH 15/20] docs: add jsdoc blocks tyo all type classes --- src/type/Type.js | 40 ++++++++++++++++++++++++++++++- src/type/resolved/ResolvedType.js | 36 ++++++++++++++++++++++++++++ src/type/resolved/SlugType.js | 38 +++++++++++++++++++++++++++++ src/type/simple/BooleanType.js | 13 ++++++++++ src/type/simple/DateType.js | 24 +++++++++++++++++-- src/type/simple/NumberType.js | 13 ++++++++++ src/type/simple/SimpleType.js | 8 +++++++ src/type/simple/StringType.js | 13 ++++++++++ 8 files changed, 182 insertions(+), 3 deletions(-) diff --git a/src/type/Type.js b/src/type/Type.js index f906607..6e82301 100644 --- a/src/type/Type.js +++ b/src/type/Type.js @@ -1,25 +1,63 @@ /** + * Base class for all data types. + * + * The `Type` class is a foundational class used to define various data types. + * It contains common properties and methods that are inherited by more specific types like strings, numbers, and booleans. + * * @class Type */ class Type { + /** + * @static + * @property {boolean} _required - Indicates if the type is required. Default is `false`. + */ static _required = false; + + /** + * @static + * @property {boolean} _resolved - Indicates if the type has been resolved. Default is `false`. + */ static _resolved = false; + + /** + * @static + * @property {*} _properties - Properties for defining schemas. Default is `undefined`. + */ static _properties = undefined; + + /** + * @static + * @property {*} _items - Represents items in array types or collections. Default is `undefined`. + */ static _items = undefined; + + /** + * @static + * @property {*} _schema - The schema definition for the type. Default is `undefined`. + */ static _schema = undefined; + /** + * Converts the class name to a string, removing the "Type" suffix. + * + * @returns {string} The name of the type without the "Type" suffix. + */ static toString() { return this.name?.replace(/Type$/, ''); } /** - * @return {Type} + * Returns a version of the type marked as required. + * + * @type {Type} + * @returns {Type} A subclass of the current type with `_required` set to `true`. */ static get required() { class Required extends this { static _required = true; } + // Define the class name as "Required" Object.defineProperty(Required, 'name', {value: `Required${this.toString()}Type`}); return Required; diff --git a/src/type/resolved/ResolvedType.js b/src/type/resolved/ResolvedType.js index e642a7b..b7e3ba6 100644 --- a/src/type/resolved/ResolvedType.js +++ b/src/type/resolved/ResolvedType.js @@ -1,14 +1,50 @@ import Type from '../Type.js'; +/** + * Class representing a resolved type. + * + * The `ResolvedType` class extends the base `Type` class and marks the type as resolved. + * It provides additional functionality for resolving types and allows creating resolved types based on properties. + * + * @class ResolvedType + * @extends Type + */ class ResolvedType extends Type { + /** + * @static + * @property {boolean} _resolved - Indicates if the type is resolved. Always set to `true` for this class. + */ static _resolved = true; + /** + * Resolves the type based on the provided model. + * + * This method should be overridden in subclasses to provide specific resolution logic. + * Throws an error if not implemented. + * + * @param {*} _model - The model used to resolve the type. + * @throws {Error} If the method is not implemented in a subclass. + */ static resolve(_model) { throw new Error(`${this.name} does not implement resolve(model)`); } + /** + * Creates a subclass of `ResolvedType` that is based on the provided property. + * + * The returned class inherits from `ResolvedType` and customizes its `toString` method + * to reflect the resolved property. + * + * @param {*} property - The property to base the resolved type on. + * @returns {ResolvedType} A subclass of `ResolvedType` customized for the provided property. + */ static of(property) { class ResolvedTypeOf extends ResolvedType { + /** + * Converts the resolved type to a string, displaying the resolved property. + * + * @returns {string} A string representing the resolved type, including the property. + */ static toString() { return `ResolvedTypeOf(${property})`; } diff --git a/src/type/resolved/SlugType.js b/src/type/resolved/SlugType.js index cb00d99..e31418f 100644 --- a/src/type/resolved/SlugType.js +++ b/src/type/resolved/SlugType.js @@ -1,15 +1,53 @@ import ResolvedType from './ResolvedType.js'; import slugify from 'slugify'; +/** + * Class representing a slug type. + * + * The `SlugType` class extends the `ResolvedType` and provides functionality for generating + * slugified strings based on a specified property of a model. It allows for the creation of + * types that resolve into slugs from specific properties. + * + * @class SlugType + * @extends ResolvedType + */ class SlugType extends ResolvedType { + /** + * Creates a subclass of `ResolvedType` that generates slugs based on the provided property. + * + * The returned class inherits from `ResolvedType` and resolves the property into a slugified + * string using the `slugify` function. It also customizes the `toString` method to reflect + * the resolved property. + * + * @param {string} property - The property to base the slug on. + * @returns {ResolvedType} A subclass of `ResolvedType` that generates slugs from the provided property. + */ static of(property) { class SlugOf extends ResolvedType { + /** + * @static + * @property {string} _type - The type of the resolved value, always set to 'string' for slug types. + */ static _type = 'string'; + /** + * Converts the slug type to a string, displaying the resolved property. + * + * @returns {string} A string representing the slug type, including the property. + */ static toString() { return `SlugOf(${property})`; } + /** + * Resolves the slug from the given model by extracting the specified property and slugifying it. + * + * If the specified property in the model is not a string, an empty string is returned. + * Uses the `slugify` function to convert the property value into a slug (lowercase, hyphen-separated). + * + * @param {Object} model - The model from which to extract and slugify the property. + * @returns {string} The slugified version of the model's property, or an empty string if not valid. + */ static resolve(model) { if (typeof model?.[property] !== 'string') return ''; diff --git a/src/type/simple/BooleanType.js b/src/type/simple/BooleanType.js index b775be1..5c838b6 100644 --- a/src/type/simple/BooleanType.js +++ b/src/type/simple/BooleanType.js @@ -1,6 +1,19 @@ import SimpleType from './SimpleType.js'; +/** + * Class representing a boolean type. + * + * This class is used to define and handle data of the boolean type. + * It extends the {@link SimpleType} class to represent string-specific behavior. + * + * @class BooleanType + * @extends SimpleType + */ class BooleanType extends SimpleType { + /** + * @static + * @property {string} _type - The type identifier for BooleanType, set to `'boolean'`. + */ static _type = 'boolean'; } diff --git a/src/type/simple/DateType.js b/src/type/simple/DateType.js index 6c06123..7e02afd 100644 --- a/src/type/simple/DateType.js +++ b/src/type/simple/DateType.js @@ -1,13 +1,33 @@ import SimpleType from './SimpleType.js'; +/** + * Class representing a date type with ISO date-time format. + * + * This class is used to define and handle data of the date type. + * It extends the {@link SimpleType} class to represent string-specific behavior. + * + * @class DateType + * @extends SimpleType + */ class DateType extends SimpleType { + /** + * @static + * @property {string} _type - The type identifier for DateType, set to `'string'`. + */ static _type = 'string'; + + /** + * @static + * @property {string} _format - The format for DateType, set to `'iso-date-time'`. + */ static _format = 'iso-date-time'; /** + * Checks if the given value is a valid date. * - * @param {Date|number|string} possibleDate - * @return {boolean} + * @static + * @param {*} possibleDate - The value to check for a valid date. + * @returns {boolean} Returns `true` if the value is a valid date or a date string, otherwise `false`. */ static isDate(possibleDate) { return possibleDate instanceof Date || !isNaN(new Date(possibleDate)); diff --git a/src/type/simple/NumberType.js b/src/type/simple/NumberType.js index 06ee1dd..c5b6558 100644 --- a/src/type/simple/NumberType.js +++ b/src/type/simple/NumberType.js @@ -1,6 +1,19 @@ import SimpleType from './SimpleType.js'; +/** + * Class representing a number type. + * + * This class is used to define and handle data of the number type. + * It extends the {@link SimpleType} class to represent string-specific behavior. + * + * @class NumberType + * @extends SimpleType + */ class NumberType extends SimpleType { + /** + * @static + * @property {string} _type - The type identifier for NumberType, set to `'number'`. + */ static _type = 'number'; } diff --git a/src/type/simple/SimpleType.js b/src/type/simple/SimpleType.js index dd182ea..e67ef8c 100644 --- a/src/type/simple/SimpleType.js +++ b/src/type/simple/SimpleType.js @@ -1,5 +1,13 @@ import Type from '../Type.js'; +/** + * Class representing a simple type. + * + * This serves as a base class for primitive or simple types such as string, number, or boolean. + * + * @class SimpleType + * @extends Type + */ class SimpleType extends Type { } diff --git a/src/type/simple/StringType.js b/src/type/simple/StringType.js index dd889fb..97e385f 100644 --- a/src/type/simple/StringType.js +++ b/src/type/simple/StringType.js @@ -1,6 +1,19 @@ import SimpleType from './SimpleType.js'; +/** + * Class representing a string type. + * + * This class is used to define and handle data of the string type. + * It extends the {@link SimpleType} class to represent string-specific behavior. + * + * @class StringType + * @extends SimpleType + */ class StringType extends SimpleType { + /** + * @static + * @property {string} _type - The type identifier for the string type. + */ static _type = 'string'; } From e7e0068ccbca07abd4184791e487f4e532dd504b Mon Sep 17 00:00:00 2001 From: Lawrence Date: Tue, 24 Sep 2024 08:27:53 +0100 Subject: [PATCH 16/20] docs: add jsdoc block for complex types --- src/type/complex/ArrayType.js | 51 ++++++++++++++++++++++++++++++++++ src/type/complex/CustomType.js | 32 +++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/type/complex/ArrayType.js b/src/type/complex/ArrayType.js index 90b714f..9ac482c 100644 --- a/src/type/complex/ArrayType.js +++ b/src/type/complex/ArrayType.js @@ -1,19 +1,70 @@ import Type from '../Type.js'; +/** + * Represents an array type definition, allowing the specification of an array of a certain type. + * This class is used to create type definitions for arrays that can be validated and used in schemas. + * + * @class ArrayType + */ class ArrayType { + /** + * Creates a new type definition for an array of the specified type. + * + * The `of` method defines an array where the items must be of the specified type. It returns a + * class representing this array type, which can further be marked as required using the `required` getter. + * + * @param {Type} type - The type of the items that the array will contain. + * @returns {Type} A new class representing an array of the specified type. + * + * @example + * const arrayOfStrings = ArrayType.of(StringType); + * const requiredArrayOfNumbers = ArrayType.of(NumberType).required; + */ static of(type) { + /** + * @class ArrayOf + * @extends Type + * Represents an array of a specific type. + */ class ArrayOf extends Type { + /** @type {string} The data type, which is 'array' */ static _type = 'array'; + + /** @type {Type} The type of items contained in the array */ static _items = type; + /** + * Returns the string representation of the array type. + * + * @returns {string} The string representation of the array type. + */ static toString() { return `ArrayOf(${type.toString()})`; } + /** + * Marks the array type as required. + * + * @returns {Type} A new class representing a required array of the specified type. + * + * @example + * const requiredArrayOfStrings = ArrayType.of(StringType).required; + */ static get required() { + /** + * @class RequiredArrayOf + * @extends ArrayOf + * Represents a required array of a specific type. + */ class Required extends this { + /** @type {boolean} Indicates that the array is required */ static _required = true; + /** + * Returns the string representation of the required array type. + * + * @returns {string} The string representation of the required array type. + */ static toString() { return `RequiredArrayOf(${type})`; } diff --git a/src/type/complex/CustomType.js b/src/type/complex/CustomType.js index a0626a2..d075fbe 100644 --- a/src/type/complex/CustomType.js +++ b/src/type/complex/CustomType.js @@ -1,12 +1,44 @@ import Type from '../Type.js'; import ajv from 'ajv'; +/** + * Represents a custom type definition, allowing the specification of a type based on a given schema. + * This class uses AJV (Another JSON Schema Validator) to validate schemas and create type definitions. + * + * @class CustomType + */ class CustomType { + /** + * Creates a new custom type definition based on the provided JSON schema. + * + * The `of` method allows defining a custom object type using a JSON schema. It validates the schema + * using AJV to ensure correctness and returns a class representing the custom type. + * + * @param {Object} schema - The JSON schema that defines the structure and validation rules for the custom type. + * @returns {Type} A new class representing the custom type based on the provided schema. + * + * @example + * const customSchema = { + * type: 'object', + * properties: { name: { type: 'string' }, age: { type: 'number' } }, + * required: ['name'], + * }; + * const CustomModel = CustomType.of(customSchema); + */ static of(schema) { + // Compiles and validates the schema using AJV new ajv().compile(schema); + /** + * @class Custom + * @extends Type + * Represents a custom type defined by a JSON schema. + */ class Custom extends Type { + /** @type {string} The data type, which is 'object' */ static _type = 'object'; + + /** @type {Object} The JSON schema that defines the structure and validation rules */ static _schema = schema; } From 21f0200eacd03c79ea3a5adb5a30c4c3ba9630ea Mon Sep 17 00:00:00 2001 From: Lawrence Date: Tue, 24 Sep 2024 08:28:29 +0100 Subject: [PATCH 17/20] docs: add jsdoc blocks for engine classes --- src/engine/Engine.js | 56 ++++++++++++++-- src/engine/FileEngine.js | 120 ++++++++++++++++++++++++++++------ src/engine/HTTPEngine.js | 138 ++++++++++++++++++++++++++++++++++++--- src/engine/S3Engine.js | 106 ++++++++++++++++++++++++++++-- 4 files changed, 381 insertions(+), 39 deletions(-) diff --git a/src/engine/Engine.js b/src/engine/Engine.js index 3285b3a..e6dd92e 100644 --- a/src/engine/Engine.js +++ b/src/engine/Engine.js @@ -36,7 +36,7 @@ class Engine { /** * Retrieves the index for a given model. This method must be implemented by subclasses. * - * @param {Object} _model - The model to retrieve the index for. + * @param {Model.constructor} _model - The model to retrieve the index for. * @throws {NotImplementedError} Throws if the method is not implemented. * @abstract */ @@ -58,7 +58,7 @@ class Engine { /** * Retrieves the compiled search index for a model. This method must be implemented by subclasses. * - * @param {Object} _model - The model to retrieve the compiled search index for. + * @param {Model.constructor} _model - The model to retrieve the compiled search index for. * @throws {NotImplementedError} Throws if the method is not implemented. * @abstract */ @@ -69,7 +69,7 @@ class Engine { /** * Retrieves the raw search index for a model. This method must be implemented by subclasses. * - * @param {Object} _model - The model to retrieve the raw search index for. + * @param {Model.constructor} _model - The model to retrieve the raw search index for. * @throws {NotImplementedError} Throws if the method is not implemented. * @abstract */ @@ -80,7 +80,7 @@ class Engine { /** * Saves the compiled search index for a model. This method must be implemented by subclasses. * - * @param {Object} _model - The model for which the compiled search index is saved. + * @param {Model.constructor} _model - The model for which the compiled search index is saved. * @param {Object} _compiledIndex - The compiled search index data. * @throws {NotImplementedError} Throws if the method is not implemented. * @abstract @@ -92,7 +92,7 @@ class Engine { /** * Saves the raw search index for a model. This method must be implemented by subclasses. * - * @param {Object} _model - The model for which the raw search index is saved. + * @param {Model.constructor} _model - The model for which the raw search index is saved. * @param {Object} _rawIndex - The raw search index data. * @throws {NotImplementedError} Throws if the method is not implemented. * @abstract @@ -132,6 +132,13 @@ class Engine { return output; } + /** + * Finds models that match a query in the model's index. + * + * @param {Model.constructor} model - The model class to search. + * @param {object} query - The query object containing search criteria. + * @returns {Array} An array of models matching the query. + */ static async find(model, query) { this.checkConfiguration(); const index = await this.getIndex(model); @@ -139,6 +146,12 @@ class Engine { return new Query(query).execute(model, index); } + /** + * Stores a model and its associated index data into the system. + * + * @param {Model} model - The model to store. + * @throws {EngineError} Throws if the model fails to validate or index fails to save. + */ static async put(model) { this.checkConfiguration(); const uploadedModels = []; @@ -193,6 +206,14 @@ class Engine { await this.putIndex(indexUpdates); } + /** + * Retrieves a model by its ID and converts it to its data representation. + * + * @param {Model.constructor} model - The model class to retrieve. + * @param {string} id - The ID of the model to retrieve. + * @returns {Model} The found model. + * @throws {NotFoundEngineError} Throws if the model is not found. + */ static async get(model, id) { this.checkConfiguration(); @@ -205,6 +226,12 @@ class Engine { } } + /** + * Hydrates a model by populating its related properties (e.g., submodels) from stored data. + * + * @param {Model} model - The model to hydrate. + * @returns {Model} The hydrated model. + */ static async hydrate(model) { this.checkConfiguration(); const hydratedModels = {}; @@ -271,20 +298,37 @@ class Engine { return await hydrateModel(await this.get(model.constructor, model.id)); } + /** + * Configures the engine with specific settings. + * + * @param {Object} configuration - The configuration settings for the engine. + * @returns {Engine} A new engine instance with the applied configuration. + */ static configure(configuration) { class ConfiguredStore extends this { static configuration = configuration; } - Object.defineProperty(ConfiguredStore, 'name', {value: `${this.toString()}`}); + Object.defineProperty(ConfiguredStore, 'name', { value: `${this.toString()}` }); return ConfiguredStore; } + /** + * Checks if the engine is properly configured. + * + * @throws {MissConfiguredError} Throws if the engine is misconfigured. + * @abstract + */ static checkConfiguration() { } + /** + * Returns the name of the engine class. + * + * @returns {string} The name of the engine class. + */ static toString() { return this.name; } diff --git a/src/engine/FileEngine.js b/src/engine/FileEngine.js index aa723ba..c96054e 100644 --- a/src/engine/FileEngine.js +++ b/src/engine/FileEngine.js @@ -1,16 +1,36 @@ -import Engine, {EngineError, MissConfiguredError} from './Engine.js'; -import {dirname, join} from 'node:path'; +import Engine, { EngineError, MissConfiguredError } from './Engine.js'; +import { dirname, join } from 'node:path'; import fs from 'node:fs/promises'; +/** + * Custom error class for FileEngine-related errors. + * Extends the base `EngineError` class. + */ class FileEngineError extends EngineError {} +/** + * Error thrown when writing to a file fails in `FileEngine`. + * Extends the `FileEngineError` class. + */ class FailedWriteFileEngineError extends FileEngineError {} /** + * `FileEngine` class extends the base `Engine` class to implement + * file system-based storage and retrieval of model data. + * * @class FileEngine * @extends Engine */ class FileEngine extends Engine { + /** + * Configures the FileEngine with a given configuration object. + * Adds default `filesystem` configuration if not provided. + * + * @param {Object} configuration - Configuration settings for FileEngine. + * @param {Object} [configuration.filesystem] - Custom filesystem module (default: Node.js fs/promises). + * @param {Object} [configuration.path] - The absolute path on the filesystem to write models to. + * @returns {FileEngine} A configured instance of FileEngine. + */ static configure(configuration) { if (!configuration.filesystem) { configuration.filesystem = fs; @@ -18,37 +38,70 @@ class FileEngine extends Engine { return super.configure(configuration); } + /** + * Checks if the FileEngine has been configured correctly. + * Ensures that `path` and `filesystem` settings are present. + * + * @throws {MissConfiguredError} Throws if required configuration is missing. + */ static checkConfiguration() { - if ( - !this.configuration?.path || - !this.configuration?.filesystem - ) throw new MissConfiguredError(this.configuration); + if (!this.configuration?.path || !this.configuration?.filesystem) { + throw new MissConfiguredError(this.configuration); + } } + /** + * Retrieves a model by its ID from the file system. + * + * @param {string} id - The ID of the model to retrieve. + * @returns {Object} The parsed model data. + * @throws {Error} Throws if the file cannot be read or parsed. + */ static async getById(id) { const filePath = join(this.configuration.path, `${id}.json`); - - return JSON.parse(await this.configuration.filesystem.readFile(filePath).then(f => f.toString())); + return JSON.parse( + await this.configuration.filesystem.readFile(filePath).then((f) => f.toString()), + ); } + /** + * Retrieves the index for a given model from the file system. + * + * @param {Model.constructor?} model - The model for which the index is retrieved. + * @returns {Object} The parsed index data. + * @throws {Error} Throws if the file cannot be read. + */ static async getIndex(model) { - return JSON.parse((await this.configuration.filesystem.readFile(join(this.configuration.path, model.name, '_index.json')).catch(() => '{}')).toString()); + return JSON.parse( + (await this.configuration.filesystem.readFile(join(this.configuration.path, model?.toString(), '_index.json')).catch(() => '{}')).toString(), + ); } + /** + * Saves a model to the file system. + * + * @param {Model} model - The model to save. + * @throws {FailedWriteFileEngineError} Throws if the model cannot be written to the file system. + */ static async putModel(model) { const filePath = join(this.configuration.path, `${model.id}.json`); - try { - await this.configuration.filesystem.mkdir(dirname(filePath), {recursive: true}); + await this.configuration.filesystem.mkdir(dirname(filePath), { recursive: true }); await this.configuration.filesystem.writeFile(filePath, JSON.stringify(model.toData())); } catch (error) { throw new FailedWriteFileEngineError(`Failed to put file://${filePath}`, error); } } + /** + * Saves the index for multiple models to the file system. + * + * @param {Object} index - An object where keys are locations and values are arrays of models. + * @throws {FailedWriteFileEngineError} Throws if the index cannot be written to the file system. + */ static async putIndex(index) { const processIndex = async (location, models) => { - const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()])); + const modelIndex = Object.fromEntries(models.map((m) => [m.id, m.toIndexData()])); const filePath = join(this.configuration.path, location, '_index.json'); const currentIndex = JSON.parse((await this.configuration.filesystem.readFile(filePath).catch(() => '{}')).toString()); @@ -69,22 +122,44 @@ class FileEngine extends Engine { await processIndex('', Object.values(index).flat()); } + /** + * Retrieves the compiled search index for a model from the file system. + * + * @param {Model.constructor} model - The model for which the search index is retrieved. + * @returns {Object} The parsed compiled search index. + * @throws {Error} Throws if the file cannot be read. + */ static async getSearchIndexCompiled(model) { - return await this.configuration.filesystem.readFile(join(this.configuration.path, model.name, '_search_index.json')) - .then(b => b.toString()) + return await this.configuration.filesystem + .readFile(join(this.configuration.path, model.toString(), '_search_index.json')) + .then((b) => b.toString()) .then(JSON.parse); } + /** + * Retrieves the raw search index for a model from the file system. + * + * @param {Model.constructor} model - The model for which the raw search index is retrieved. + * @returns {Object} The parsed raw search index. + * @throws {Error} Throws if the file cannot be read. + */ static async getSearchIndexRaw(model) { - return await this.configuration.filesystem.readFile(join(this.configuration.path, model.name, '_search_index_raw.json')) - .then(b => b.toString()) + return await this.configuration.filesystem + .readFile(join(this.configuration.path, model.toString(), '_search_index_raw.json')) + .then((b) => b.toString()) .then(JSON.parse) .catch(() => ({})); } + /** + * Saves the compiled search index for a model to the file system. + * + * @param {Model.constructor} model - The model for which the compiled search index is saved. + * @param {Object} compiledIndex - The compiled search index to save. + * @throws {FailedWriteFileEngineError} Throws if the compiled index cannot be written to the file system. + */ static async putSearchIndexCompiled(model, compiledIndex) { - const filePath = join(this.configuration.path, model.name, '_search_index.json'); - + const filePath = join(this.configuration.path, model.toString(), '_search_index.json'); try { await this.configuration.filesystem.writeFile(filePath, JSON.stringify(compiledIndex)); } catch (error) { @@ -92,8 +167,15 @@ class FileEngine extends Engine { } } + /** + * Saves the raw search index for a model to the file system. + * + * @param {Model.constructor} model - The model for which the raw search index is saved. + * @param {Object} rawIndex - The raw search index to save. + * @throws {FailedWriteFileEngineError} Throws if the raw index cannot be written to the file system. + */ static async putSearchIndexRaw(model, rawIndex) { - const filePath = join(this.configuration.path, model.name, '_search_index_raw.json'); + const filePath = join(this.configuration.path, model.toString(), '_search_index_raw.json'); try { await this.configuration.filesystem.writeFile(filePath, JSON.stringify(rawIndex)); } catch (error) { diff --git a/src/engine/HTTPEngine.js b/src/engine/HTTPEngine.js index e3c1d58..76e4a9a 100644 --- a/src/engine/HTTPEngine.js +++ b/src/engine/HTTPEngine.js @@ -1,8 +1,21 @@ import Engine, {EngineError, MissConfiguredError} from './Engine.js'; -export class HTTPEngineError extends EngineError { -} +/** + * Represents an error specific to HTTP engine operations. + * @class HTTPEngineError + * @extends EngineError + */ +export class HTTPEngineError extends EngineError {} +/** + * Error indicating a failed HTTP request. + * @class HTTPRequestFailedError + * @extends HTTPEngineError + * + * @param {string} url - The URL of the failed request. + * @param {Object} options - The options used in the fetch request. + * @param {Response} response - The HTTP response object. + */ export class HTTPRequestFailedError extends HTTPEngineError { constructor(url, options, response) { const method = options.method?.toLowerCase() || 'get'; @@ -13,7 +26,25 @@ export class HTTPRequestFailedError extends HTTPEngineError { } } +/** + * HTTPEngine is an extension of the Engine class that provides methods for interacting with HTTP-based APIs. + * It uses the Fetch API for sending and receiving data. + * + * @class HTTPEngine + * @extends Engine + */ class HTTPEngine extends Engine { + + /** + * Configures the HTTP engine with additional fetch options. + * Sets the Accept header to 'application/json' by default. + * + * @param {Object} configuration - Configuration object containing fetch options and other settings. + * @param {string} [configuration.host] - Hostname and protocol of the HTTP service to use (ie: https://example.com). + * @param {string?} [configuration.prefix] - The prefix on the host to perform operations against. + * @param {Object} [configuration.fetchOptions] - Fetch overrides to attach to all requests sent to the HTTP service. + * @returns {Object} The configured settings for the HTTP engine. + */ static configure(configuration = {}) { configuration.fetchOptions = { ...(configuration.fetchOptions ?? {}), @@ -26,16 +57,30 @@ class HTTPEngine extends Engine { return super.configure(configuration); } + /** + * Validates the engine's configuration, ensuring that the host is defined. + * + * @throws {MissConfiguredError} Thrown if the configuration is missing a host. + */ static checkConfiguration() { - if ( - !this.configuration?.host - ) throw new MissConfiguredError(this.configuration); + if (!this.configuration?.host) throw new MissConfiguredError(this.configuration); } + /** + * Returns the fetch options for reading operations. + * + * @returns {Object} The fetch options for reading. + */ static _getReadOptions() { return this.configuration.fetchOptions; } + /** + * Returns the fetch options for writing (PUT) operations. + * Sets the method to PUT and adds a Content-Type header of 'application/json'. + * + * @returns {Object} The fetch options for writing. + */ static _getWriteOptions() { return { ...this._getReadOptions(), @@ -47,6 +92,16 @@ class HTTPEngine extends Engine { }; } + /** + * Processes a fetch request with error handling. Throws an error if the response is not successful. + * + * @param {string|URL} url - The URL to fetch. + * @param {Object} options - The fetch options. + * @param {*} [defaultValue] - A default value to return if the fetch fails. + * @returns {Promise} The parsed JSON response. + * + * @throws {HTTPRequestFailedError} Thrown if the fetch request fails. + */ static async _processFetch(url, options, defaultValue = undefined) { return this.configuration.fetch(url, options) .then(response => { @@ -63,6 +118,14 @@ class HTTPEngine extends Engine { .then(r => r.json()); } + /** + * Retrieves an object by its ID from the server. + * + * @param {string} id - The ID of the object to retrieve. + * @returns {Promise} The retrieved object in JSON format. + * + * @throws {HTTPRequestFailedError} Thrown if the fetch request fails. + */ static async getById(id) { this.checkConfiguration(); @@ -75,6 +138,14 @@ class HTTPEngine extends Engine { return await this._processFetch(url, this._getReadOptions()); } + /** + * Uploads (puts) a model object to the server. + * + * @param {Model} model - The model object to upload. + * @returns {Promise} The response from the server. + * + * @throws {HTTPRequestFailedError} Thrown if the PUT request fails. + */ static async putModel(model) { const url = new URL([ this.configuration.host, @@ -88,6 +159,14 @@ class HTTPEngine extends Engine { }); } + /** + * Uploads (puts) an index object to the server. + * + * @param {Object} index - The index data to upload, organized by location. + * @returns {Promise} + * + * @throws {HTTPRequestFailedError} Thrown if the PUT request fails. + */ static async putIndex(index) { const processIndex = async (location, models) => { const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()])); @@ -114,12 +193,29 @@ class HTTPEngine extends Engine { await processIndex(null, Object.values(index).flat()); } - static async getIndex(location) { - const url = new URL([this.configuration.host, this.configuration.prefix, location, '_index.json'].filter(e => Boolean(e)).join('/')); + /** + * Retrieves the index object from the server for the specified location. + * + * @param {Model.constructor?} model - The model in the host where the index is stored. + * @returns {Promise} The index data in JSON format. + */ + static async getIndex(model) { + const url = new URL([ + this.configuration.host, + this.configuration.prefix, + model?.toString(), + '_index.json', + ].filter(e => Boolean(e)).join('/')); return await this._processFetch(url, this._getReadOptions(), {}); } + /** + * Retrieves the compiled search index for a model from the server. + * + * @param {Model.constructor} model - The model whose compiled search index to retrieve. + * @returns {Promise} The compiled search index in JSON format. + */ static async getSearchIndexCompiled(model) { const url = new URL([ this.configuration.host, @@ -131,6 +227,12 @@ class HTTPEngine extends Engine { return await this._processFetch(url, this._getReadOptions()); } + /** + * Retrieves the raw (uncompiled) search index for a model from the server. + * + * @param {Model.constructor} model - The model whose raw search index to retrieve. + * @returns {Promise} The raw search index in JSON format, or an empty object if not found. + */ static async getSearchIndexRaw(model) { const url = new URL([ this.configuration.host, @@ -142,11 +244,20 @@ class HTTPEngine extends Engine { return await this._processFetch(url, this._getReadOptions()).catch(() => ({})); } + /** + * Uploads (puts) a compiled search index for a model to the server. + * + * @param {Model.constructor} model - The model whose compiled search index to upload. + * @param {Object} compiledIndex - The compiled search index data. + * @returns {Promise} The response from the server. + * + * @throws {HTTPRequestFailedError} Thrown if the PUT request fails. + */ static async putSearchIndexCompiled(model, compiledIndex) { const url = new URL([ this.configuration.host, this.configuration.prefix, - model.name, + model.toString(), '_search_index.json', ].filter(e => !!e).join('/')); @@ -156,11 +267,20 @@ class HTTPEngine extends Engine { }); } + /** + * Uploads (puts) a raw search index for a model to the server. + * + * @param {Model.constructor} model - The model whose raw search index to upload. + * @param {Object} rawIndex - The raw search index data. + * @returns {Promise} The response from the server. + * + * @throws {HTTPRequestFailedError} Thrown if the PUT request fails. + */ static async putSearchIndexRaw(model, rawIndex) { const url = new URL([ this.configuration.host, this.configuration.prefix, - model.name, + model.toString(), '_search_index_raw.json', ].filter(e => !!e).join('/')); diff --git a/src/engine/S3Engine.js b/src/engine/S3Engine.js index 90b9055..81bd515 100644 --- a/src/engine/S3Engine.js +++ b/src/engine/S3Engine.js @@ -1,11 +1,47 @@ import Engine, {EngineError, MissConfiguredError} from './Engine.js'; import {GetObjectCommand, PutObjectCommand} from '@aws-sdk/client-s3'; +/** + * Represents an error specific to the S3 engine operations. + * @class S3EngineError + * @extends EngineError + */ class S3EngineError extends EngineError {} +/** + * Error indicating a failure when putting an object to S3. + * @class FailedPutS3EngineError + * @extends S3EngineError + */ class FailedPutS3EngineError extends S3EngineError {} +/** + * S3Engine is an extension of the Engine class that provides methods for interacting with AWS S3. + * It allows for storing, retrieving, and managing model data in an S3 bucket. + * + * @class S3Engine + * @extends Engine + */ class S3Engine extends Engine { + /** + * Configures the S3 engine with additional options. + * + * @param {Object} configuration - Configuration object. + * @param {S3Client} [configuration.client] - An S3 client used to process operations. + * @param {string} [configuration.bucket] - The S3 bucket to perform operations against. + * @param {string?} [configuration.prefix] - The optional prefix in the bucket to perform operations against. + * @returns {Object} The configured settings for the HTTP engine. + */ + static configure(configuration = {}) { + return super.configure(configuration); + } + + /** + * Validates the S3 engine configuration to ensure necessary parameters (bucket and client) are present. + * Throws an error if the configuration is invalid. + * + * @throws {MissConfiguredError} Thrown when the configuration is missing required parameters. + */ static checkConfiguration() { if ( !this.configuration?.bucket || @@ -13,6 +49,14 @@ class S3Engine extends Engine { ) throw new MissConfiguredError(this.configuration); } + /** + * Retrieves an object from S3 by its ID. + * + * @param {string} id - The ID of the object to retrieve. + * @returns {Promise} The parsed JSON object retrieved from S3. + * + * @throws {Error} Thrown if there is an issue with the S3 client request. + */ static async getById(id) { const objectPath = [this.configuration.prefix, `${id}.json`].join('/'); @@ -24,6 +68,14 @@ class S3Engine extends Engine { return JSON.parse(await data.Body.transformToString()); } + /** + * Puts (uploads) a model object to S3. + * + * @param {Model} model - The model object to upload. + * @returns {Promise} + * + * @throws {FailedPutS3EngineError} Thrown if there is an error during the S3 PutObject operation. + */ static async putModel(model) { const Key = [this.configuration.prefix, `${model.id}.json`].join('/'); @@ -39,10 +91,16 @@ class S3Engine extends Engine { } } - static async getIndex(location) { + /** + * Retrieves the index object from S3 at the specified location. + * + * @param {Model.constructor?} model - The model in the bucket where the index is stored. + * @returns {Promise} The parsed index object. + */ + static async getIndex(model) { try { const data = await this.configuration.client.send(new GetObjectCommand({ - Key: [this.configuration.prefix, location, '_index.json'].filter(e => Boolean(e)).join('/'), + Key: [this.configuration.prefix, model?.toString(), '_index.json'].filter(e => Boolean(e)).join('/'), Bucket: this.configuration.bucket, })); @@ -52,6 +110,14 @@ class S3Engine extends Engine { } } + /** + * Puts (uploads) an index object to S3. + * + * @param {Object} index - The index data to upload, organized by location. + * @returns {Promise} + * + * @throws {FailedPutS3EngineError} Thrown if there is an error during the S3 PutObject operation. + */ static async putIndex(index) { const processIndex = async (location, models) => { const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()])); @@ -81,6 +147,12 @@ class S3Engine extends Engine { await processIndex(null, Object.values(index).flat()); } + /** + * Retrieves the compiled search index for a specific model from S3. + * + * @param {Model.constructor} model - The model whose search index to retrieve. + * @returns {Promise} The compiled search index. + */ static async getSearchIndexCompiled(model) { return await this.configuration.client.send(new GetObjectCommand({ Key: [this.configuration.prefix, model.name, '_search_index.json'].join('/'), @@ -89,17 +161,32 @@ class S3Engine extends Engine { .then(JSON.parse); } + /** + * Retrieves the raw (uncompiled) search index for a specific model from S3. + * + * @param {Model.constructor} model - The model whose raw search index to retrieve. + * @returns {Promise} The raw search index, or an empty object if not found. + */ static async getSearchIndexRaw(model) { return await this.configuration.client.send(new GetObjectCommand({ - Key: [this.configuration.prefix, model.name, '_search_index_raw.json'].join('/'), + Key: [this.configuration.prefix, model.toString(), '_search_index_raw.json'].join('/'), Bucket: this.configuration.bucket, })).then(data => data.Body.transformToString()) .then(JSON.parse) .catch(() => ({})); } + /** + * Puts (uploads) a compiled search index for a specific model to S3. + * + * @param {Model.constructor} model - The model whose compiled search index to upload. + * @param {Object} compiledIndex - The compiled search index data. + * @returns {Promise} + * + * @throws {FailedPutS3EngineError} Thrown if there is an error during the S3 PutObject operation. + */ static async putSearchIndexCompiled(model, compiledIndex) { - const Key = [this.configuration.prefix, model.name, '_search_index.json'].join('/'); + const Key = [this.configuration.prefix, model.toString(), '_search_index.json'].join('/'); try { await this.configuration.client.send(new PutObjectCommand({ @@ -113,8 +200,17 @@ class S3Engine extends Engine { } } + /** + * Puts (uploads) a raw search index for a specific model to S3. + * + * @param {Model.constructor} model - The model whose raw search index to upload. + * @param {Object} rawIndex - The raw search index data. + * @returns {Promise} + * + * @throws {FailedPutS3EngineError} Thrown if there is an error during the S3 PutObject operation. + */ static async putSearchIndexRaw(model, rawIndex) { - const Key = [this.configuration.prefix, model.name, '_search_index_raw.json'].join('/'); + const Key = [this.configuration.prefix, model.toString(), '_search_index_raw.json'].join('/'); try { await this.configuration.client.send(new PutObjectCommand({ From 5727394e9e8a24be1ccac23d694b06012c0fe162 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Tue, 24 Sep 2024 08:29:08 +0100 Subject: [PATCH 18/20] docs: add jsdoc block for calledWith test function --- test/assertions.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/assertions.js b/test/assertions.js index 1b1726b..d532e04 100644 --- a/test/assertions.js +++ b/test/assertions.js @@ -12,6 +12,21 @@ function parseArgument(arg) { } } +/** + * Checks if a spy function was called with the specified arguments. + * + * This function iterates through all calls made to the provided spy and compares the arguments + * of each call with the expected arguments. If a match is found, it asserts that the arguments + * match using the provided test function. If no match is found, it throws an error detailing + * the expected and actual arguments. + * + * @param {Object} t - The test object that provides assertion methods, such as `like`. + * @param {Function} spy - The spy function whose calls are being inspected. + * @param {...*} args - The expected arguments that should match one of the spy's calls. + * + * @throws {Error} If the spy was not called with the given arguments, an error is thrown + * containing details about the actual calls and expected arguments. + */ function calledWith(t, spy, ...args) { for (const call of spy.getCalls()) { const calledArguments = call.args.map(parseArgument); From 25b45f879a2351ec2ee908829c388405938c6ed5 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Tue, 24 Sep 2024 08:33:09 +0100 Subject: [PATCH 19/20] refactor: file retrival consistency --- src/engine/FileEngine.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/engine/FileEngine.js b/src/engine/FileEngine.js index c96054e..0fb0a14 100644 --- a/src/engine/FileEngine.js +++ b/src/engine/FileEngine.js @@ -58,10 +58,10 @@ class FileEngine extends Engine { * @throws {Error} Throws if the file cannot be read or parsed. */ static async getById(id) { - const filePath = join(this.configuration.path, `${id}.json`); - return JSON.parse( - await this.configuration.filesystem.readFile(filePath).then((f) => f.toString()), - ); + return this.configuration.filesystem + .readFile(join(this.configuration.path, `${id}.json`)) + .then((b) => b.toString()) + .then(JSON.parse); } /** @@ -72,9 +72,11 @@ class FileEngine extends Engine { * @throws {Error} Throws if the file cannot be read. */ static async getIndex(model) { - return JSON.parse( - (await this.configuration.filesystem.readFile(join(this.configuration.path, model?.toString(), '_index.json')).catch(() => '{}')).toString(), - ); + return this.configuration.filesystem + .readFile(join(this.configuration.path, model.toString(), '_index.json')) + .then((b) => b.toString()) + .catch(() => '{}') + .then(JSON.parse); } /** @@ -130,7 +132,7 @@ class FileEngine extends Engine { * @throws {Error} Throws if the file cannot be read. */ static async getSearchIndexCompiled(model) { - return await this.configuration.filesystem + return this.configuration.filesystem .readFile(join(this.configuration.path, model.toString(), '_search_index.json')) .then((b) => b.toString()) .then(JSON.parse); @@ -144,7 +146,7 @@ class FileEngine extends Engine { * @throws {Error} Throws if the file cannot be read. */ static async getSearchIndexRaw(model) { - return await this.configuration.filesystem + return this.configuration.filesystem .readFile(join(this.configuration.path, model.toString(), '_search_index_raw.json')) .then((b) => b.toString()) .then(JSON.parse) From 4b1fee8146fae803cb6afc5eeb24b1d17bcc47d9 Mon Sep 17 00:00:00 2001 From: Lawrence Date: Tue, 24 Sep 2024 08:43:00 +0100 Subject: [PATCH 20/20] refactor: async function should have await expression --- src/engine/FileEngine.js | 16 ++++++++-------- src/engine/HTTPEngine.test.js | 30 ++++++++++-------------------- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/src/engine/FileEngine.js b/src/engine/FileEngine.js index 0fb0a14..cedb100 100644 --- a/src/engine/FileEngine.js +++ b/src/engine/FileEngine.js @@ -54,10 +54,10 @@ class FileEngine extends Engine { * Retrieves a model by its ID from the file system. * * @param {string} id - The ID of the model to retrieve. - * @returns {Object} The parsed model data. + * @returns {Promise} The parsed model data. * @throws {Error} Throws if the file cannot be read or parsed. */ - static async getById(id) { + static getById(id) { return this.configuration.filesystem .readFile(join(this.configuration.path, `${id}.json`)) .then((b) => b.toString()) @@ -68,10 +68,10 @@ class FileEngine extends Engine { * Retrieves the index for a given model from the file system. * * @param {Model.constructor?} model - The model for which the index is retrieved. - * @returns {Object} The parsed index data. + * @returns {Promise} The parsed index data. * @throws {Error} Throws if the file cannot be read. */ - static async getIndex(model) { + static getIndex(model) { return this.configuration.filesystem .readFile(join(this.configuration.path, model.toString(), '_index.json')) .then((b) => b.toString()) @@ -128,10 +128,10 @@ class FileEngine extends Engine { * Retrieves the compiled search index for a model from the file system. * * @param {Model.constructor} model - The model for which the search index is retrieved. - * @returns {Object} The parsed compiled search index. + * @returns {Promise} The parsed compiled search index. * @throws {Error} Throws if the file cannot be read. */ - static async getSearchIndexCompiled(model) { + static getSearchIndexCompiled(model) { return this.configuration.filesystem .readFile(join(this.configuration.path, model.toString(), '_search_index.json')) .then((b) => b.toString()) @@ -142,10 +142,10 @@ class FileEngine extends Engine { * Retrieves the raw search index for a model from the file system. * * @param {Model.constructor} model - The model for which the raw search index is retrieved. - * @returns {Object} The parsed raw search index. + * @returns {Promise} The parsed raw search index. * @throws {Error} Throws if the file cannot be read. */ - static async getSearchIndexRaw(model) { + static getSearchIndexRaw(model) { return this.configuration.filesystem .readFile(join(this.configuration.path, model.toString(), '_search_index_raw.json')) .then((b) => b.toString()) diff --git a/src/engine/HTTPEngine.test.js b/src/engine/HTTPEngine.test.js index e2336a3..afa4398 100644 --- a/src/engine/HTTPEngine.test.js +++ b/src/engine/HTTPEngine.test.js @@ -267,16 +267,14 @@ test('HTTPEngine.put(model) when the engine fails to put a compiled search index return Promise.resolve({ ok: false, status: 500, - json: async () => { - throw new Error(); - }, + json: () => Promise.reject(new Error()), }); } return Promise.resolve({ ok: true, status: 200, - json: async () => ({}), + json: () => Promise.resolve({}), }); }); @@ -332,16 +330,14 @@ test('HTTPEngine.put(model) when the engine fails to put a raw search index', as return Promise.resolve({ ok: false, status: 500, - json: async () => { - throw new Error(); - }, + json: () => Promise.reject(new Error()), }); } return Promise.resolve({ ok: true, status: 200, - json: async () => ({}), + json: () => Promise.resolve({}), }); }); @@ -388,16 +384,14 @@ test('HTTPEngine.put(model) when putting an index fails', async t => { return Promise.resolve({ ok: false, status: 500, - json: async () => { - throw new Error(); - }, + json: () => Promise.reject(new Error()), }); } return Promise.resolve({ ok: true, status: 200, - json: async () => ({}), + json: () => Promise.resolve({}), }); }); @@ -444,16 +438,14 @@ test('HTTPEngine.put(model) when the initial model put fails', async t => { return Promise.resolve({ ok: false, status: 500, - json: async () => { - throw new Error(); - }, + json: () => Promise.reject(new Error()), }); } return Promise.resolve({ ok: true, status: 200, - json: async () => ({}), + json: () => Promise.resolve({}), }); }); @@ -489,16 +481,14 @@ test('HTTPEngine.put(model) when the engine fails to put a linked model', async return Promise.resolve({ ok: false, status: 500, - json: async () => { - throw new Error(); - }, + json: () => Promise.reject(new Error()), }); } return Promise.resolve({ ok: true, status: 200, - json: async () => ({}), + json: () => Promise.resolve({}), }); });