From 2d9cf6864fd8a32bbf210cb86a8675c8ff79b7c7 Mon Sep 17 00:00:00 2001 From: Jo Colina Date: Sat, 26 Mar 2022 09:40:13 +0100 Subject: [PATCH] Models: add simple model class --- index.js | 3 +- lib/model.js | 50 +++++++++++ package-lock.json | 208 +++++++++++++++++++++++++++++++++++++++++++- package.json | 3 +- test/model/model.js | 159 +++++++++++++++++++++++++++++++++ test/setup.js | 2 + test/utils/kv.js | 8 ++ 7 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 lib/model.js create mode 100644 test/model/model.js create mode 100644 test/utils/kv.js diff --git a/index.js b/index.js index 5b504da..1add863 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ const Router = require('./lib/router'); const App = require('./lib/app'); +const Model = require('./lib/model'); -module.exports = { Router, App }; +module.exports = { Router, App, Model }; diff --git a/lib/model.js b/lib/model.js new file mode 100644 index 0000000..1cb6f55 --- /dev/null +++ b/lib/model.js @@ -0,0 +1,50 @@ +class Model { + static KV_BINDING = null; + static PREFIX = ''; + + static get_id(id) { + const separator = this.PREFIX ? '-' : ''; + return `${this.PREFIX}${separator}${id}`; + } + + static id(instance) { + return instance.id; + } + + static get(id) { + return this.KV_BINDING.get(this.get_id(id)).then(data => { + if(data === null) { + return null; + } + + const model_data = JSON.parse(data); + return this.fromJSON(model_data); + }); + } + + static bind_kv(binding) { + this.KV_BINDING = binding; + } + + static fromJSON(data) { + return new this(data); + } + + toJSON() { + return {...this}; + } + + save() { + const id = this.constructor.id(this); + if(!id) { + throw new Error('Cannot save model with no id'); + } + + return this.constructor.KV_BINDING.put( + this.constructor.get_id(id), + JSON.stringify(this) + ).then(() => this); + } +} + +module.exports = Model; diff --git a/package-lock.json b/package-lock.json index ecee914..aba7116 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,45 @@ "license": "MIT", "devDependencies": { "chai": "^4.3.6", - "mocha": "^9.2.1" + "mocha": "^9.2.1", + "sinon": "^13.0.1" } }, + "node_modules/@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.1.tgz", + "integrity": "sha512-Wp5vwlZ0lOqpSYGKqr53INws9HLkt6JDc/pDZcPf7bchQnrXJMXPns8CXx0hFikMSGSWfvtvvpb2gtMVfkWagA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.1.tgz", + "integrity": "sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, "node_modules/@ungap/promise-all-settled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", @@ -557,6 +593,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -575,6 +617,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -590,6 +638,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -688,6 +742,19 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nise": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.1.tgz", + "integrity": "sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": ">=5", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -754,6 +821,15 @@ "node": ">=0.10.0" } }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/pathval": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", @@ -834,6 +910,36 @@ "randombytes": "^2.1.0" } }, + "node_modules/sinon": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-13.0.1.tgz", + "integrity": "sha512-8yx2wIvkBjIq/MGY1D9h1LMraYW+z1X0mb648KZnKSdvLasvDu7maa0dFaNYdTDczFgbjNw2tOmWdTk9saVfwQ==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": "^9.0.0", + "@sinonjs/samsam": "^6.1.1", + "diff": "^5.0.0", + "nise": "^5.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1017,6 +1123,41 @@ } }, "dependencies": { + "@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.1.tgz", + "integrity": "sha512-Wp5vwlZ0lOqpSYGKqr53INws9HLkt6JDc/pDZcPf7bchQnrXJMXPns8CXx0hFikMSGSWfvtvvpb2gtMVfkWagA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/samsam": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.1.tgz", + "integrity": "sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, "@ungap/promise-all-settled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", @@ -1410,6 +1551,12 @@ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1425,6 +1572,12 @@ "argparse": "^2.0.1" } }, + "just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1434,6 +1587,12 @@ "p-locate": "^5.0.0" } }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, "log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -1506,6 +1665,19 @@ "integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==", "dev": true }, + "nise": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.1.tgz", + "integrity": "sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": ">=5", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1551,6 +1723,15 @@ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + }, "pathval": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", @@ -1602,6 +1783,31 @@ "randombytes": "^2.1.0" } }, + "sinon": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-13.0.1.tgz", + "integrity": "sha512-8yx2wIvkBjIq/MGY1D9h1LMraYW+z1X0mb648KZnKSdvLasvDu7maa0dFaNYdTDczFgbjNw2tOmWdTk9saVfwQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": "^9.0.0", + "@sinonjs/samsam": "^6.1.1", + "diff": "^5.0.0", + "nise": "^5.1.1", + "supports-color": "^7.2.0" + }, + "dependencies": { + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", diff --git a/package.json b/package.json index aa5bd11..f4be031 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "license": "MIT", "devDependencies": { "chai": "^4.3.6", - "mocha": "^9.2.1" + "mocha": "^9.2.1", + "sinon": "^13.0.1" }, "publishConfig": { "access": "public" diff --git a/test/model/model.js b/test/model/model.js new file mode 100644 index 0000000..00e63fc --- /dev/null +++ b/test/model/model.js @@ -0,0 +1,159 @@ +const { expect } = require('chai'); +const Sinon = require('sinon'); +const path = require('path'); + +const model_path = path.join(__dirname, '../../lib/model.js'); + + +describe('Models', () => { + beforeEach(() => { + // Clear require cache + delete require.cache[model_path]; + }); + + it('Should bind a KV namespace to the base model', () => { + const Model = require('../../lib/model'); + expect(Model.KV_BINDING).to.be.null; + + const test = {}; + Model.bind_kv(test); + + expect(Model.KV_BINDING).to.be.eql(test); + }); + + it('Should bind a KV namespace caching on require', () => { + const Model = require('../../lib/model'); + expect(Model.KV_BINDING).to.be.null; + + const test = {}; + Model.bind_kv(test); + + expect(Model.KV_BINDING).to.be.eql(test); + + const ReimportedModel = require('../../lib/model'); + expect(ReimportedModel.KV_BINDING).to.be.eql(test); + }); + + it('Should get an ID with a prefix', () => { + const Model = require('../../lib/model'); + class MyModel extends Model { + static PREFIX = 'pref'; + }; + + expect(Model.get_id('plep')).to.be.eql('plep'); + expect(MyModel.get_id('plep')).to.be.eql('pref-plep'); + }); + + describe('DB operations', () => { + it('Should find a model and instanciate it (no fromJSON)', done => { + const Model = require('../../lib/model'); + Model.bind_kv(global.KV_MOCK); + + class MyModel extends Model { + PREFIX = 'get'; + + constructor({ a, b, c}) { + super(); + this.a = a; + this.b = b; + this.c = c; + } + }; + + // Mock the get method from KV + const get_stub = Sinon.stub(global.KV_MOCK, 'get').callsFake(() => Promise.resolve(JSON.stringify({ + a: 1, + b: 2, + c: 3 + }))); + + MyModel.get('test_id').then(model => { + expect(model).to.be.an.instanceof(MyModel); + expect(model.a).to.be.eql(1); + expect(model.b).to.be.eql(2); + expect(model.c).to.be.eql(3); + get_stub.restore(); + done(); + }).catch(e => { + get_stub.restore(); + done(e); + }); + }); + + it('Should find a model and instanciate it (with fromJSON)', done => { + const Model = require('../../lib/model'); + Model.bind_kv(global.KV_MOCK); + + class MyModel extends Model { + PREFIX = 'get'; + + constructor({ a, b }, c) { + super(); + this.a = a; + this.b = b; + this.c = c; + } + + static fromJSON({ a, b, c }) { + return new this({ a, b }, c); + } + }; + + // Mock the get method from KV + const get_stub = Sinon.stub(global.KV_MOCK, 'get').callsFake(() => Promise.resolve(JSON.stringify({ + a: 1, + b: 2, + c: 3 + }))); + + MyModel.get('test_id').then(model => { + expect(model).to.be.an.instanceof(MyModel); + expect(model.a).to.be.eql(1); + expect(model.b).to.be.eql(2); + expect(model.c).to.be.eql(3); + expect(get_stub.calledOnce).to.be.true; + get_stub.restore(); + done(); + }).catch(e => { + get_stub.restore(); + done(e); + }); + }); + + it('Should save a model to KV', done => { + const Model = require('../../lib/model'); + Model.bind_kv(global.KV_MOCK); + + class MyModel extends Model { + static id({ a }) { + return a; + } + + constructor({ a, b }) { + super(); + this.a = a; + this.b = b; + } + } + + const my_model = new MyModel({ a: 5, b: 7 }); + + const save_stub = Sinon.stub(global.KV_MOCK, 'put').callsFake(() => Promise.resolve()); + + my_model.save().then(model => { + expect(model).to.be.eql(my_model); + expect(save_stub.calledOnce).to.be.true; + expect(save_stub.calledWith( + '5', + JSON.stringify({ a: 5, b: 7 }) + )).to.be.true; + + save_stub.restore(); + done(); + }).catch(e => { + save_stub.restore(); + done(e); + }); + }); + }); +}); diff --git a/test/setup.js b/test/setup.js index af6967b..e98e554 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,8 +1,10 @@ const { Request, Response } = require('./utils/http'); +const { KV_BINDING } = require('./utils/kv'); before(() => { global.Response = Response; global.Request = Request; + global.KV_MOCK = KV_BINDING; }); after(() => { diff --git a/test/utils/kv.js b/test/utils/kv.js new file mode 100644 index 0000000..9b5273b --- /dev/null +++ b/test/utils/kv.js @@ -0,0 +1,8 @@ +const KV_BINDING = { + get: () => Promise.resolve(), + put: () => Promise.resolve(), + delete: () => Promise.resolve(), + list: () => Promise.resolve() +}; + +module.exports = { KV_BINDING };