From b58b0a8d1628cb41c8de534a20c5121c3deda426 Mon Sep 17 00:00:00 2001 From: Justin Wind Date: Tue, 15 Jul 2025 14:52:47 -0700 Subject: [PATCH] use db-helper module, assorted rework/cleanup --- README.md | 2 + package-lock.json | 110 ++++- package.json | 10 +- server.js | 74 ++-- src/authenticator.js | 5 +- src/db/abstract.js | 117 +++++ src/db/base.js | 8 +- src/db/index-old.js | 43 ++ src/db/index.js | 44 +- src/db/{postgres => postgres-old}/index.js | 16 +- .../sql/auth-get-by-id.sql | 0 .../sql/auth-upsert.sql | 0 .../sql/link-access.sql | 0 .../sql/link-expire.sql | 0 .../sql/link-get-all.sql | 0 .../sql/link-get-by-id.sql | 0 .../sql/link-get-by-url.sql | 0 .../sql/link-update.sql | 0 .../sql/link-upsert.sql | 0 .../sql/schema/1.0.0/apply.sql | 6 +- .../sql/schema/1.0.0/revert.sql | 0 .../sql/schema/1.0.1/apply.sql | 0 .../sql/schema/1.0.1/revert.sql | 0 .../sql/schema/init.sql | 0 src/db/postgresql/index.js | 182 ++++++++ src/db/postgresql/sql/auth-get-by-id.sql | 1 + src/db/postgresql/sql/auth-upsert.sql | 4 + src/db/postgresql/sql/link-access.sql | 6 + src/db/postgresql/sql/link-expire.sql | 4 + src/db/postgresql/sql/link-get-all.sql | 3 + src/db/postgresql/sql/link-get-by-id.sql | 2 + src/db/postgresql/sql/link-get-by-url.sql | 2 + src/db/postgresql/sql/link-upsert.sql | 7 + src/db/postgresql/sql/schema/1.0.0/apply.sql | 47 ++ src/db/postgresql/sql/schema/1.0.0/revert.sql | 7 + src/db/postgresql/sql/schema/1.0.1/apply.sql | 8 + src/db/postgresql/sql/schema/1.0.1/revert.sql | 9 + src/db/sqlite-old/index.js | 343 ++++++++++++++ src/db/sqlite/index.js | 417 +++++++----------- src/db/sqlite/sql/auth-get-by-id.sql | 1 + src/db/sqlite/sql/auth-insert.sql | 1 + src/db/sqlite/sql/auth-update.sql | 1 + src/db/sqlite/sql/link-access.sql | 1 + src/db/sqlite/sql/link-expire.sql | 1 + src/db/sqlite/sql/link-get-all.sql | 1 + src/db/sqlite/sql/link-get-by-id.sql | 1 + src/db/sqlite/sql/link-get-by-url.sql | 1 + src/db/sqlite/sql/link-insert.sql | 1 + src/db/sqlite/sql/link-update.sql | 1 + src/db/sqlite/sql/schema/1.0.0/apply.sql | 22 + src/db/sqlite/sql/schema/1.0.0/revert.sql | 6 + src/db/sqlite/sql/schema/1.0.1/apply.sql | 12 + src/db/sqlite/sql/schema/1.0.1/revert.sql | 6 + src/manager.js | 33 +- src/service.js | 53 +-- src/template/info-html.js | 15 +- src/template/report-html.js | 15 +- test/src/db/abstract.js | 38 ++ test/src/db/base.js | 3 +- test/src/db/factory.js | 39 ++ test/src/db/index.js | 54 --- test/src/db/integration.js | 192 ++++++++ test/src/db/postgres.js | 236 ++++++++++ test/src/db/postgres/index.js | 43 +- test/src/db/sqlite.js | 396 +++++++++++++++++ test/src/db/sqlite/index.js | 75 +--- test/src/manager.js | 16 +- test/src/service.js | 10 - test/stub-db.js | 6 + 69 files changed, 2147 insertions(+), 610 deletions(-) create mode 100644 src/db/abstract.js create mode 100644 src/db/index-old.js rename src/db/{postgres => postgres-old}/index.js (95%) rename src/db/{postgres => postgres-old}/sql/auth-get-by-id.sql (100%) rename src/db/{postgres => postgres-old}/sql/auth-upsert.sql (100%) rename src/db/{postgres => postgres-old}/sql/link-access.sql (100%) rename src/db/{postgres => postgres-old}/sql/link-expire.sql (100%) rename src/db/{postgres => postgres-old}/sql/link-get-all.sql (100%) rename src/db/{postgres => postgres-old}/sql/link-get-by-id.sql (100%) rename src/db/{postgres => postgres-old}/sql/link-get-by-url.sql (100%) rename src/db/{postgres => postgres-old}/sql/link-update.sql (100%) rename src/db/{postgres => postgres-old}/sql/link-upsert.sql (100%) rename src/db/{postgres => postgres-old}/sql/schema/1.0.0/apply.sql (87%) rename src/db/{postgres => postgres-old}/sql/schema/1.0.0/revert.sql (100%) rename src/db/{postgres => postgres-old}/sql/schema/1.0.1/apply.sql (100%) rename src/db/{postgres => postgres-old}/sql/schema/1.0.1/revert.sql (100%) rename src/db/{postgres => postgres-old}/sql/schema/init.sql (100%) create mode 100644 src/db/postgresql/index.js create mode 100644 src/db/postgresql/sql/auth-get-by-id.sql create mode 100644 src/db/postgresql/sql/auth-upsert.sql create mode 100644 src/db/postgresql/sql/link-access.sql create mode 100644 src/db/postgresql/sql/link-expire.sql create mode 100644 src/db/postgresql/sql/link-get-all.sql create mode 100644 src/db/postgresql/sql/link-get-by-id.sql create mode 100644 src/db/postgresql/sql/link-get-by-url.sql create mode 100644 src/db/postgresql/sql/link-upsert.sql create mode 100644 src/db/postgresql/sql/schema/1.0.0/apply.sql create mode 100644 src/db/postgresql/sql/schema/1.0.0/revert.sql create mode 100644 src/db/postgresql/sql/schema/1.0.1/apply.sql create mode 100644 src/db/postgresql/sql/schema/1.0.1/revert.sql create mode 100644 src/db/sqlite-old/index.js create mode 100644 src/db/sqlite/sql/auth-get-by-id.sql create mode 100644 src/db/sqlite/sql/auth-insert.sql create mode 100644 src/db/sqlite/sql/auth-update.sql create mode 100644 src/db/sqlite/sql/link-access.sql create mode 100644 src/db/sqlite/sql/link-expire.sql create mode 100644 src/db/sqlite/sql/link-get-all.sql create mode 100644 src/db/sqlite/sql/link-get-by-id.sql create mode 100644 src/db/sqlite/sql/link-get-by-url.sql create mode 100644 src/db/sqlite/sql/link-insert.sql create mode 100644 src/db/sqlite/sql/link-update.sql create mode 100644 src/db/sqlite/sql/schema/1.0.0/apply.sql create mode 100644 src/db/sqlite/sql/schema/1.0.0/revert.sql create mode 100644 src/db/sqlite/sql/schema/1.0.1/apply.sql create mode 100644 src/db/sqlite/sql/schema/1.0.1/revert.sql create mode 100644 test/src/db/abstract.js create mode 100644 test/src/db/factory.js delete mode 100644 test/src/db/index.js create mode 100644 test/src/db/integration.js create mode 100644 test/src/db/postgres.js create mode 100644 test/src/db/sqlite.js create mode 100644 test/stub-db.js diff --git a/README.md b/README.md index 3681c58..b39bf11 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,5 @@ The world needed another link shortener. * PUT /:link { token, [url] } - update short link url, or create with admin access * GET /admin/report - data about all links * GET /static/:asset - serve asset + +Exists mostly as a testbed for libraries and experiments and such. diff --git a/package-lock.json b/package-lock.json index c590834..b80880d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@squeep/api-dingus": "^2", + "@squeep/api-dingus": "file:../node-api-dingus", + "@squeep/cache": "file:../node-cache", + "@squeep/db-helper": "file:../node-db-helper", "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.6.1", + "@squeep/log-helper": "^1.0.1", "@squeep/logger-json-console": "^3", "argon2": "^0.43.0", "better-sqlite3": "^11", @@ -20,11 +23,12 @@ }, "devDependencies": { "@squeep/eslint-config": "^1", - "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.1", + "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.2", "eslint": "^9", "html-validate": "^9", "husky": "^9", "mocha": "^11", + "mocha-steps": "^1.3.0", "nyc": "^17", "sinon": "^21" }, @@ -32,6 +36,64 @@ "node": ">=16.4" } }, + "../node-api-dingus": { + "name": "@squeep/api-dingus", + "version": "2.1.3", + "license": "ISC", + "dependencies": { + "@squeep/log-helper": "^1", + "mime-db": "^1", + "uuid": "^11" + }, + "devDependencies": { + "@squeep/eslint-config": "^1", + "eslint": "^9", + "husky": "^9", + "mocha": "^11", + "nyc": "^17", + "sinon": "^21" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "../node-cache": { + "name": "@squeep/cache", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@squeep/eslint-config": "^1", + "eslint": "^9", + "husky": "^9", + "mocha": "^11", + "nyc": "^17", + "sinon": "^21" + } + }, + "../node-db-helper": { + "name": "@squeep/db-helper", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@squeep/log-helper": "^1", + "better-sqlite3": "^12", + "pg-promise": "^11", + "uuid": "^11" + }, + "devDependencies": { + "@squeep/eslint-config": "^1", + "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.1", + "eslint": "^9", + "husky": "^9", + "mocha": "^11", + "mocha-steps": "^1", + "nyc": "^17", + "sinon": "^21" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -1024,17 +1086,16 @@ } }, "node_modules/@squeep/api-dingus": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@squeep/api-dingus/-/api-dingus-2.1.3.tgz", - "integrity": "sha512-e3olO2INqkUS2XjOnXEeRb0CWlwQ4AaB29hgTyx0Xy8/dRETD5fTXFSoNqGYjPJs29eFLkZR/Q0iBvG07IGesw==", - "dependencies": { - "@squeep/log-helper": "^1", - "mime-db": "^1", - "uuid": "^11" - }, - "engines": { - "node": ">=14.13.1" - } + "resolved": "../node-api-dingus", + "link": true + }, + "node_modules/@squeep/cache": { + "resolved": "../node-cache", + "link": true + }, + "node_modules/@squeep/db-helper": { + "resolved": "../node-db-helper", + "link": true }, "node_modules/@squeep/eslint-config": { "version": "1.0.2", @@ -1092,9 +1153,12 @@ } }, "node_modules/@squeep/test-helper": { - "version": "3.0.1", - "resolved": "git+https://git.squeep.com/squeep-test-helper#062a2d18088fc8b41a126a0d8dcc3af6723d7bca", - "dev": true + "version": "3.0.2", + "resolved": "git+https://git.squeep.com/squeep-test-helper#1104db678456d6cbe9139f7be35ce887abc9d655", + "dev": true, + "engines": { + "node": "^20.13 || >=22" + } }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", @@ -3621,14 +3685,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -3720,6 +3776,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/mocha-steps": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/mocha-steps/-/mocha-steps-1.3.0.tgz", + "integrity": "sha512-KZvpMJTqzLZw3mOb+EEuYi4YZS41C9iTnb7skVFRxHjUd1OYbl64tCMSmpdIRM9LnwIrSOaRfPtNpF5msgv6Eg==", + "dev": true + }, "node_modules/mocha/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", diff --git a/package.json b/package.json index 918cf34..aadedcc 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,11 @@ "author": "Justin Wind ", "license": "ISC", "dependencies": { - "@squeep/api-dingus": "^2", + "@squeep/api-dingus": "file:../node-api-dingus", + "@squeep/cache": "file:../node-cache", + "@squeep/db-helper": "file:../node-db-helper", "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.6.1", + "@squeep/log-helper": "^1.0.1", "@squeep/logger-json-console": "^3", "argon2": "^0.43.0", "better-sqlite3": "^11", @@ -32,11 +35,12 @@ }, "devDependencies": { "@squeep/eslint-config": "^1", - "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.1", + "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.2", "eslint": "^9", - "husky": "^9", "html-validate": "^9", + "husky": "^9", "mocha": "^11", + "mocha-steps": "^1.3.0", "nyc": "^17", "sinon": "^21" } diff --git a/server.js b/server.js index 1dece03..1770dd9 100644 --- a/server.js +++ b/server.js @@ -1,42 +1,66 @@ 'use strict'; const http = require('node:http'); +const path = require('node:path'); const { AsyncLocalStorage } = require('node:async_hooks'); const DB = require('./src/db'); const Logger = require('./src/logger'); const Service = require('./src/service'); -const { fileScope } = require('./src/common'); +const { fileScope } = require('@squeep/log-helper'); const { version } = require('./package.json'); const _scope = fileScope(__filename)('main'); -const PORT = process.env.PORT || 3001; +const PORT = parseInt(process.env.PORT ?? 3001, 10); const ADDR = process.env.LISTEN_ADDR || '127.0.0.1'; -const asyncLocalStorage = new AsyncLocalStorage(); -const logger = new Logger({ - // ignoreBelowLevel: 'info', -}, {}, asyncLocalStorage); -const db = new DB(logger, { - connectionString: 'postgresql://%2Fhome%2Ffek%2Fprojects%2F_db%2Fpostgres_dev-15/urlittler', - // connectionString: 'sqlite://', -}); -const service = new Service(logger, db, { - proxyPrefix: process.env.PROXY_PREFIX || '', // '/lil', - manager: { - selfBaseUrl: process.env.BASE_URL || '', +const config = { + dingus: { + proxyPrefix: process.env.PROXY_PREFIX || '', // e.g. '/lil', + selfBaseUrl: process.env.BASE_URL || `http://localhost:${PORT}/`, + staticPath: path.normalize(path.join(__dirname, 'static')), }, -}, asyncLocalStorage); + db: { + connectionString: 'postgresql://%2Fhome%2Ffek%2Fprojects%2F_db%2Fpostgres_dev-15/urlittler', + // connectionString: 'sqlite://', + }, +}; + +/** + * + */ +async function main() { + const asyncLocalStorage = new AsyncLocalStorage(); + const logger = new Logger({ + ignoreBelowLevel: 'debug', + }, {}, asyncLocalStorage); + const db = new DB(logger, config); + await db.initialize(); + + const service = new Service(logger, db, config.dingus, asyncLocalStorage); -http.createServer((req, res) => { - asyncLocalStorage.run({}, async () => { - await service.dispatch(req, res); + const server = http.createServer(); + server.on('request', (req, res) => { + asyncLocalStorage.run({}, async () => { + await service.dispatch(req, res); + }); }); -}).listen(PORT, ADDR, (err) => { - if (err) { - logger.error(_scope, 'error starting server:', err); - throw err; - } - logger.info(_scope, 'server started', { version, listenAddress: ADDR, listenPort: PORT }); -}); + server.on('error', (...args) => logger.error(_scope, 'server error', { args })); + server.on('listening', (...args) => logger.info(_scope, 'server listening', { ...args, PORT, ADDR, version })); + server.listen(PORT, ADDR); + + // http.createServer((req, res) => { + // asyncLocalStorage.run({}, async () => { + // await service.dispatch(req, res); + // }); + // }).listen(PORT, ADDR, (err) => { + // if (err) { + // logger.error(_scope, 'error starting server:', err); + // throw err; + // } + // logger.info(_scope, 'server started', { version, listenAddress: ADDR, listenPort: PORT }); + // }); +} + +main().catch(console.error); diff --git a/src/authenticator.js b/src/authenticator.js index eeb6f20..274f197 100644 --- a/src/authenticator.js +++ b/src/authenticator.js @@ -5,8 +5,9 @@ const crypto = require('node:crypto'); const common = require('./common'); const Enum = require('./enum'); const { ResponseError } = require('./errors'); +const { fileScope } = require('@squeep/log-helper'); -const _fileScope = common.fileScope(__filename); +const _fileScope = fileScope(__filename); /** * Let us roll our own authentication dingus. @@ -296,7 +297,7 @@ class Authenticator { * @param {http.ClientRequest} req request * @param {http.ServerResponse} res response * @param {object} ctx context - * @returns {Promise} + * @returns {Promise} valid */ async required(req, res, ctx) { const _scope = _fileScope('required'); diff --git a/src/db/abstract.js b/src/db/abstract.js new file mode 100644 index 0000000..9625fda --- /dev/null +++ b/src/db/abstract.js @@ -0,0 +1,117 @@ +'use strict'; + +const { Abstract } = require('@squeep/db-helper'); + +class Database extends Abstract { + + /** + * Interface + */ + /* eslint-disable no-unused-vars */ + + /** + * @typedef {object} Auth + * @property {string} id id + * @property {string} secret secret + * @property {string=} password credential + */ + + /** + * @typedef {object} Link + * @property {string} id id + * @property {string} url url + * @property {Date} created created + * @property {Date|Infinity} lastAccess last access + * @property {number} accesses accesses + * @property {Date=} expires expires + * @property {string=} authToken auth token + * @property {boolean} isSpecial special link + */ + + /** + * + * @param {*} dbCtx db context + * @param {string} id id + * @returns {Promise} auth + */ + async getAuthById(dbCtx, id) { + this._notImplemented('getAuthById', arguments); + } + + /** + * + * @param {*} dbCtx db context + * @param {string} id id + * @param {string} secret secret + * @param {string=} credential credential + * @returns {Promise} + */ + async upsertAuth(dbCtx, id, secret, credential) { + this._notImplemented('upsertAuthCredential', arguments); + } + + /** + * + * @param {*} dbCtx db context + * @param {string} id id + * @param {string} url string + * @param {string=} authToken token + * @returns {Promise} + */ + async upsertLink(dbCtx, id, url, authToken) { + this._notImplemented('upsertLink', arguments); + } + + /** + * + * @param {*} dbCtx db context + * @param {string} id id + * @returns {Promise} link + */ + async getLinkById(dbCtx, id) { + this._notImplemented('getLinkById', arguments); + } + + /** + * + * @param {*} dbCtx db context + * @param {string} url url + * @returns {Promise} link + */ + async getLinkByUrl(dbCtx, url) { + this._notImplemented('getLinkByUrl', arguments); + } + + /** + * + * @param {*} dbCtx db context + * @param {string} id id + * @returns {Promise} link + */ + async accessLink(dbCtx, id) { + this._notImplemented('accessLink', arguments); + } + + /** + * + * @param {*} dbCtx db context + * @param {string} id id + * @param {Date} expires expire date + * @returns {Promise} + */ + async expireLink(dbCtx, id, expires) { + this._notImplemented('expireLink', arguments); + } + + /** + * + * @param {*} dbCtx db context + * @returns {Promise} links + */ + async getAllLinks(dbCtx) { + this._notImplemented('getAllLinks', arguments); + } + +} + +module.exports = Database; \ No newline at end of file diff --git a/src/db/base.js b/src/db/base.js index 6bce83b..db20422 100644 --- a/src/db/base.js +++ b/src/db/base.js @@ -43,8 +43,8 @@ class BaseDatabase { this._notImplemented('upsertAuthCredential', { dbCtx, id, credential }); } - async insertLink(dbCtx, id, url, authToken) { - this._notImplemented('insertLink', { dbCtx, id, url, authToken }); + async upsertLink(dbCtx, id, url, authToken) { + this._notImplemented('upsertLink', { dbCtx, id, url, authToken }); } async getLinkById(dbCtx, id) { @@ -63,10 +63,6 @@ class BaseDatabase { this._notImplemented('expireLink', { dbCtx, id, expires }); } - async updateLink(dbCtx, id, url) { - this._notImplemented('updateLink', { dbCtx, id, url }); - } - async getAllLinks(dbCtx) { this._notImplemented('getAllLinks', { dbCtx }); } diff --git a/src/db/index-old.js b/src/db/index-old.js new file mode 100644 index 0000000..b7c9549 --- /dev/null +++ b/src/db/index-old.js @@ -0,0 +1,43 @@ +'use strict'; + +const common = require('../common'); +const DBErrors = require('./errors'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + connectionString: '', +}; + +class DatabaseFactory { + constructor(logger, options, ...rest) { + const _scope = _fileScope + ':constructor'; + options = { ...defaultOptions, ...options }; + const protocol = options.connectionString.slice(0, options.connectionString.indexOf('://')).toLowerCase(); + switch (protocol) { + case DatabaseFactory.Engines.PostgreSQL: { + const Postgres = require('./postgres-old'); + return new Postgres(logger, options, ...rest); + } + + case DatabaseFactory.Engines.SQLite: { + const SQLite = require('./sqlite-old'); + return new SQLite(logger, options, ...rest); + } + + default: + logger.error(_scope, 'unsupported connectionString', { options }); + throw new DBErrors.UnsupportedEngine(protocol); + } + } + + static get Engines() { + return { + PostgreSQL: 'postgresql', + SQLite: 'sqlite', + }; + } + +} + +module.exports = DatabaseFactory; diff --git a/src/db/index.js b/src/db/index.js index acb8cba..730808e 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -1,43 +1,17 @@ 'use strict'; -const common = require('../common'); -const DBErrors = require('./errors'); +const { Factory } = require('@squeep/db-helper'); -const _fileScope = common.fileScope(__filename); - -const defaultOptions = { - connectionString: '', -}; - -class DatabaseFactory { - constructor(logger, options, ...rest) { - const _scope = _fileScope + ':constructor'; - options = { ...defaultOptions, ...options }; - const protocol = options.connectionString.slice(0, options.connectionString.indexOf('://')).toLowerCase(); - switch (protocol) { - case DatabaseFactory.Engines.PostgreSQL: { - const Postgres = require('./postgres'); - return new Postgres(logger, options, ...rest); - } - - case DatabaseFactory.Engines.SQLite: { - const SQLite = require('./sqlite'); - return new SQLite(logger, options, ...rest); - } - - default: - logger.error(_scope, 'unsupported connectionString', { options }); - throw new DBErrors.UnsupportedEngine(protocol); - } +class DatabaseFactory extends Factory { + constructor(logger, options, ...engineArguments) { + super('.', logger, options, ...engineArguments); } - static get Engines() { - return { - PostgreSQL: 'postgresql', - SQLite: 'sqlite', - }; + // eslint-disable-next-line class-methods-use-this + localRequire(enginePath) { + return require(enginePath); // eslint-disable-line security/detect-non-literal-require } -} +} // DatabaseFactory -module.exports = DatabaseFactory; +module.exports = DatabaseFactory; \ No newline at end of file diff --git a/src/db/postgres/index.js b/src/db/postgres-old/index.js similarity index 95% rename from src/db/postgres/index.js rename to src/db/postgres-old/index.js index 7549133..775a0a6 100644 --- a/src/db/postgres/index.js +++ b/src/db/postgres-old/index.js @@ -231,8 +231,8 @@ class PostgresDatabase extends BaseDatabase { }; } - async insertLink(dbCtx, id, url, authToken) { - const _scope = _fileScope('insertLink'); + async upsertLink(dbCtx, id, url, authToken) { + const _scope = _fileScope('upsertLink'); this.logger.debug(_scope, 'called', { id, url, authToken }); dbCtx = dbCtx || this.db; @@ -291,18 +291,6 @@ class PostgresDatabase extends BaseDatabase { } - async updateLink(dbCtx, id, url) { - const _scope = _fileScope('updateLink'); - this.logger.debug(_scope, 'called', { id, url }); - - dbCtx = dbCtx || this.db; - - const result = await dbCtx.result(this.statement.linkUpdate, { id, url }); - this.logger.debug(_scope, 'result', PostgresDatabase._resultLog(result) ); - return this._postgresInfo(result); - } - - async getAllLinks(dbCtx) { const _scope = _fileScope('getAllLinks'); this.logger.debug(_scope, 'called', { }); diff --git a/src/db/postgres/sql/auth-get-by-id.sql b/src/db/postgres-old/sql/auth-get-by-id.sql similarity index 100% rename from src/db/postgres/sql/auth-get-by-id.sql rename to src/db/postgres-old/sql/auth-get-by-id.sql diff --git a/src/db/postgres/sql/auth-upsert.sql b/src/db/postgres-old/sql/auth-upsert.sql similarity index 100% rename from src/db/postgres/sql/auth-upsert.sql rename to src/db/postgres-old/sql/auth-upsert.sql diff --git a/src/db/postgres/sql/link-access.sql b/src/db/postgres-old/sql/link-access.sql similarity index 100% rename from src/db/postgres/sql/link-access.sql rename to src/db/postgres-old/sql/link-access.sql diff --git a/src/db/postgres/sql/link-expire.sql b/src/db/postgres-old/sql/link-expire.sql similarity index 100% rename from src/db/postgres/sql/link-expire.sql rename to src/db/postgres-old/sql/link-expire.sql diff --git a/src/db/postgres/sql/link-get-all.sql b/src/db/postgres-old/sql/link-get-all.sql similarity index 100% rename from src/db/postgres/sql/link-get-all.sql rename to src/db/postgres-old/sql/link-get-all.sql diff --git a/src/db/postgres/sql/link-get-by-id.sql b/src/db/postgres-old/sql/link-get-by-id.sql similarity index 100% rename from src/db/postgres/sql/link-get-by-id.sql rename to src/db/postgres-old/sql/link-get-by-id.sql diff --git a/src/db/postgres/sql/link-get-by-url.sql b/src/db/postgres-old/sql/link-get-by-url.sql similarity index 100% rename from src/db/postgres/sql/link-get-by-url.sql rename to src/db/postgres-old/sql/link-get-by-url.sql diff --git a/src/db/postgres/sql/link-update.sql b/src/db/postgres-old/sql/link-update.sql similarity index 100% rename from src/db/postgres/sql/link-update.sql rename to src/db/postgres-old/sql/link-update.sql diff --git a/src/db/postgres/sql/link-upsert.sql b/src/db/postgres-old/sql/link-upsert.sql similarity index 100% rename from src/db/postgres/sql/link-upsert.sql rename to src/db/postgres-old/sql/link-upsert.sql diff --git a/src/db/postgres/sql/schema/1.0.0/apply.sql b/src/db/postgres-old/sql/schema/1.0.0/apply.sql similarity index 87% rename from src/db/postgres/sql/schema/1.0.0/apply.sql rename to src/db/postgres-old/sql/schema/1.0.0/apply.sql index ddfaaff..c05abc9 100644 --- a/src/db/postgres/sql/schema/1.0.0/apply.sql +++ b/src/db/postgres-old/sql/schema/1.0.0/apply.sql @@ -42,7 +42,11 @@ BEGIN; ); -- system entries - INSERT INTO link (id, url) VALUES ('static', '/static/index.html'), ('favicon.ico', '/static/favicon.ico'), ('robots.txt', '/static/robots.txt'); + INSERT INTO link (id, url) VALUES + ('static', '/static/index.html'), + ('favicon.ico', '/static/favicon.ico'), + ('robots.txt', '/static/robots.txt') + ; -- migration complete INSERT INTO _meta_schema_version (major, minor, patch) VALUES (1, 0, 0); diff --git a/src/db/postgres/sql/schema/1.0.0/revert.sql b/src/db/postgres-old/sql/schema/1.0.0/revert.sql similarity index 100% rename from src/db/postgres/sql/schema/1.0.0/revert.sql rename to src/db/postgres-old/sql/schema/1.0.0/revert.sql diff --git a/src/db/postgres/sql/schema/1.0.1/apply.sql b/src/db/postgres-old/sql/schema/1.0.1/apply.sql similarity index 100% rename from src/db/postgres/sql/schema/1.0.1/apply.sql rename to src/db/postgres-old/sql/schema/1.0.1/apply.sql diff --git a/src/db/postgres/sql/schema/1.0.1/revert.sql b/src/db/postgres-old/sql/schema/1.0.1/revert.sql similarity index 100% rename from src/db/postgres/sql/schema/1.0.1/revert.sql rename to src/db/postgres-old/sql/schema/1.0.1/revert.sql diff --git a/src/db/postgres/sql/schema/init.sql b/src/db/postgres-old/sql/schema/init.sql similarity index 100% rename from src/db/postgres/sql/schema/init.sql rename to src/db/postgres-old/sql/schema/init.sql diff --git a/src/db/postgresql/index.js b/src/db/postgresql/index.js new file mode 100644 index 0000000..adbdb4f --- /dev/null +++ b/src/db/postgresql/index.js @@ -0,0 +1,182 @@ +'use strict'; + +const { PostgresCreator, Errors: { UnexpectedResult } } = require('@squeep/db-helper'); +const { fileScope } = require('@squeep/log-helper'); +const Abstract = require('../abstract'); +const assert = require('node:assert'); + +const _fileScope = fileScope(__filename); + +const schemaVersionsSupported = { + min: { + major: 1, + minor: 0, + patch: 0, + }, + max: { + major: 1, + minor: 0, + patch: 1, + }, +}; + +class DatabasePostgres extends PostgresCreator(Abstract) { + + constructor(...args) { + super(...args); + /* istanbul ignore next */ + if (!this._isProduction) { + this._tableNames.push( + 'link', + 'auth', + ); + } + } + + // eslint-disable-next-line class-methods-use-this + get schemaVersionsSupported() { + return schemaVersionsSupported; + } + + + async initialize(sqlPath = __dirname, applyMigrations = true) { + /* istanbul ignore next */ + await super.initialize(sqlPath, applyMigrations); + } + + + static _authDbToNative(auth) { + return auth && { + ...auth, + }; + } + + + static _linkDbToNative(link) { + return link && { + ...link, + }; + } + + + async getAuthById(dbCtx, id) { + const _scope = _fileScope('getAuthById'); + this.logger.debug(_scope, 'called', { id }); + + try { + const auth = await dbCtx.oneOrNone(this.statement.authGetById, { id }); + return this.constructor._authDbToNative(auth); + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id }); + throw err; + } + } + + + async upsertAuth(dbCtx, id, secret, credential) { + const _scope = _fileScope('upsertAuth'); + this.logger.debug(_scope, 'called', { id }); + + try { + const result = await dbCtx.result(this.statement.authUpsert, { id, secret, credential }); + if (result.rowCount != 1) { + throw new UnexpectedResult('did not insert auth'); + } + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id }); + throw err; + } + } + + + async upsertLink(dbCtx, id, url, authToken) { + const _scope = _fileScope('upsertLink'); + this.logger.debug(_scope, 'called', { id, url, authToken }); + + try { + const result = await dbCtx.result(this.statement.linkUpsert, { id, url, authToken }); + if (result.rowCount != 1) { + throw new UnexpectedResult('did not insert link'); + } + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id, url, authToken }); + throw err; + } + } + + + async getLinkById(dbCtx, id) { + const _scope = _fileScope('getLinkById'); + this.logger.debug(_scope, 'called', { id }); + + try { + const link = await dbCtx.oneOrNone(this.statement.linkGetById, { id }); + return this.constructor._linkDbToNative(link); + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id }); + throw err; + } + } + + + async getLinkByUrl(dbCtx, url) { + const _scope = _fileScope('getLinkByUrl'); + this.logger.debug(_scope, 'called', { url }); + + try { + const link = await dbCtx.oneOrNone(this.statement.linkGetByUrl, { url }); + return this.constructor._linkDbToNative(link); + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, url }); + throw err; + } + } + + + async accessLink(dbCtx, id) { + const _scope = _fileScope('accessLink'); + this.logger.debug(_scope, 'called', { id }); + + try { + return await dbCtx.oneOrNone(this.statement.linkAccess, { id }); + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id }); + throw err; + } + } + + + async expireLink(dbCtx, id, expires) { + const _scope = _fileScope('expireLink'); + this.logger.debug(_scope, 'called', { id, expires }); + + assert(expires instanceof Date); + + try { + const result = await dbCtx.result(this.statement.linkExpire, { expires, id }); + if (result.rowCount != 1) { + throw new UnexpectedResult('did not expire link'); + } + } catch (err) { + this.logger.error(_scope, 'failed', { id, expires }); + throw err; + } + } + + + async getAllLinks(dbCtx) { + const _scope = _fileScope('getAllLinks'); + this.logger.debug(_scope, 'called', { }); + + try { + const links = await dbCtx.manyOrNone(this.statement.linkGetAll); + return links.map((link) => this.constructor._linkDbToNative(link)); + } catch (err) { + this.logger.error(_scope, 'failed', { error: err }); + throw err; + } + } + +} // DatabasePostgres + +module.exports = DatabasePostgres; \ No newline at end of file diff --git a/src/db/postgresql/sql/auth-get-by-id.sql b/src/db/postgresql/sql/auth-get-by-id.sql new file mode 100644 index 0000000..98f0434 --- /dev/null +++ b/src/db/postgresql/sql/auth-get-by-id.sql @@ -0,0 +1 @@ +SELECT * FROM auth WHERE id = $(id) \ No newline at end of file diff --git a/src/db/postgresql/sql/auth-upsert.sql b/src/db/postgresql/sql/auth-upsert.sql new file mode 100644 index 0000000..f90be73 --- /dev/null +++ b/src/db/postgresql/sql/auth-upsert.sql @@ -0,0 +1,4 @@ +-- +INSERT INTO auth (id, secret, password) VALUES ($(id), $(secret), $(credential)) +ON CONFLICT (id) DO +UPDATE SET password = $(credential), secret = $(secret) diff --git a/src/db/postgresql/sql/link-access.sql b/src/db/postgresql/sql/link-access.sql new file mode 100644 index 0000000..0c63042 --- /dev/null +++ b/src/db/postgresql/sql/link-access.sql @@ -0,0 +1,6 @@ +UPDATE link +SET + accesses = accesses + 1, + last_access = now() +WHERE id = $(id) +RETURNING * diff --git a/src/db/postgresql/sql/link-expire.sql b/src/db/postgresql/sql/link-expire.sql new file mode 100644 index 0000000..66aa809 --- /dev/null +++ b/src/db/postgresql/sql/link-expire.sql @@ -0,0 +1,4 @@ +UPDATE link +SET + expires = $(expires) +WHERE id = $(id) diff --git a/src/db/postgresql/sql/link-get-all.sql b/src/db/postgresql/sql/link-get-all.sql new file mode 100644 index 0000000..0036268 --- /dev/null +++ b/src/db/postgresql/sql/link-get-all.sql @@ -0,0 +1,3 @@ +SELECT * +FROM link +ORDER BY id \ No newline at end of file diff --git a/src/db/postgresql/sql/link-get-by-id.sql b/src/db/postgresql/sql/link-get-by-id.sql new file mode 100644 index 0000000..3f656ec --- /dev/null +++ b/src/db/postgresql/sql/link-get-by-id.sql @@ -0,0 +1,2 @@ +SELECT * +FROM link WHERE id = $(id) \ No newline at end of file diff --git a/src/db/postgresql/sql/link-get-by-url.sql b/src/db/postgresql/sql/link-get-by-url.sql new file mode 100644 index 0000000..dae277d --- /dev/null +++ b/src/db/postgresql/sql/link-get-by-url.sql @@ -0,0 +1,2 @@ +SELECT * +FROM link WHERE url = $(url) \ No newline at end of file diff --git a/src/db/postgresql/sql/link-upsert.sql b/src/db/postgresql/sql/link-upsert.sql new file mode 100644 index 0000000..8767233 --- /dev/null +++ b/src/db/postgresql/sql/link-upsert.sql @@ -0,0 +1,7 @@ +INSERT INTO link (id, url, auth_token) + VALUES + ($(id), $(url), $(authToken)) + ON CONFLICT (id) DO UPDATE + SET + url = $(url) +RETURNING * \ No newline at end of file diff --git a/src/db/postgresql/sql/schema/1.0.0/apply.sql b/src/db/postgresql/sql/schema/1.0.0/apply.sql new file mode 100644 index 0000000..cd46318 --- /dev/null +++ b/src/db/postgresql/sql/schema/1.0.0/apply.sql @@ -0,0 +1,47 @@ +-- core data +CREATE TABLE link ( + id TEXT NOT NULL PRIMARY KEY, + url TEXT NOT NULL UNIQUE, + created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + last_access TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz, + accesses INTEGER NOT NULL DEFAULT 0, + expires TIMESTAMP WITH TIME ZONE, + auth_token TEXT +); + +-- send notices to invalidate cacheable link data +CREATE OR REPLACE FUNCTION cache_invalidation_link() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + DECLARE + payload varchar; + BEGIN + IF NEW.url != OLD.url OR NEW.expires != OLD.expires + THEN + payload = 'link|' || CAST(NEW.id AS text); + PERFORM pg_notify('cache_invalidation', payload); + END IF; + RETURN NEW; + END; + $$ +; +CREATE TRIGGER cache_invalidation_link + AFTER UPDATE + ON link + FOR EACH ROW + EXECUTE PROCEDURE cache_invalidation_link(); + +-- better auth TBD +CREATE TABLE auth ( + id TEXT NOT NULL PRIMARY KEY, + secret TEXT NOT NULL, + password TEXT +); + +-- system entries +INSERT INTO link (id, url) VALUES + ('static', '/static/index.html'), + ('favicon.ico', '/static/favicon.ico'), + ('robots.txt', '/static/robots.txt') +; diff --git a/src/db/postgresql/sql/schema/1.0.0/revert.sql b/src/db/postgresql/sql/schema/1.0.0/revert.sql new file mode 100644 index 0000000..38ed34d --- /dev/null +++ b/src/db/postgresql/sql/schema/1.0.0/revert.sql @@ -0,0 +1,7 @@ +BEGIN; + DROP TRIGGER cache_invalidation_link ON link; + DROP FUNCTION cache_invalidation_link(); + DROP TABLE auth; + DROP TABLE link; + DELETE FROM _meta_schema_version WHERE major = 1 AND minor = 0 AND patch = 0; +COMMIT; diff --git a/src/db/postgresql/sql/schema/1.0.1/apply.sql b/src/db/postgresql/sql/schema/1.0.1/apply.sql new file mode 100644 index 0000000..5be80f9 --- /dev/null +++ b/src/db/postgresql/sql/schema/1.0.1/apply.sql @@ -0,0 +1,8 @@ +ALTER TABLE link + ADD COLUMN is_special BOOLEAN NOT NULL DEFAULT false +; + +UPDATE link + SET is_special = true +WHERE + url IN ('/static/robots.txt', '/static/index.html', '/static/favicon.ico'); diff --git a/src/db/postgresql/sql/schema/1.0.1/revert.sql b/src/db/postgresql/sql/schema/1.0.1/revert.sql new file mode 100644 index 0000000..08fa1c4 --- /dev/null +++ b/src/db/postgresql/sql/schema/1.0.1/revert.sql @@ -0,0 +1,9 @@ +BEGIN; + + ALTER TABLE link + DROP COLUMN is_special + ; + + DELETE FROM _meta_schema_version WHERE major = 1 AND minor = 0 AND patch = 1; + +COMMIT; diff --git a/src/db/sqlite-old/index.js b/src/db/sqlite-old/index.js new file mode 100644 index 0000000..610a3d3 --- /dev/null +++ b/src/db/sqlite-old/index.js @@ -0,0 +1,343 @@ +'use strict'; + +const SQLite = require('better-sqlite3'); +const BaseDatabase = require('../base'); +const common = require('../../common'); +const DBErrors = require('../errors'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + connectionString: '', + optimizeAfterChanges: 1000, + queryLogLevel: 'debug', +}; + +const EPOCH_NOW = '(strftime(\'%s\', \'now\'))'; + +class SQLiteDatabase extends BaseDatabase { + constructor(logger, options) { + const _scope = _fileScope('constructor'); + + super(logger); + common.setOptions(this, defaultOptions, options); + + this.dbFilename = this.connectionString.slice('sqlite://'.length) || ':memory:'; + + this.logger.debug(_scope, 'connecting', { dbFilename: this.dbFilename }); + + this.db = new SQLite(this.dbFilename, { + verbose: (query) => this.queryLogLevel && this.logger[this.queryLogLevel](_fileScope('statement'), 'executing', { query }), + }); + + this.changesSinceLastOptimize = BigInt(0); + + this.db.pragma('foreign_keys = on'); + this.db.pragma('journal_mode = WAL'); + this.db.defaultSafeIntegers(true); + + this._initTables(); + this._initStatements(); + } + + async initialize() { + this._x = true; + } + + healthCheck() { + const _scope = _fileScope('healthCheck'); + this.logger.debug(_scope, 'called', {}); + if (!this.db.open) { + throw new DBErrors.UnexpectedResult('database is not open'); + } + return { open: this.db.open }; + } + + _closeConnection() { + this.db.close(); + } + + _purgeTables(really) { + if (really) { + const extantTableNames = ['auth', 'link']; + extantTableNames.forEach((table) => { + const result = this.db.prepare(`DELETE FROM ${table}`).run(); + this.logger.debug(_fileScope('_purgeTables'), 'success', { table, result }); + }); + } + } + + _initTables() { + const _scope = _fileScope('_initTables'); + const tableExists = this.db.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=:tableName').pluck(true); + + const tables = [ + { + name: 'auth', + statements: [ + `CREATE TABLE IF NOT EXISTS auth ( + id TEXT NOT NULL PRIMARY KEY CHECK (typeof(id) = 'text'), + secret TEXT NOT NULL CHECK (typeof(secret) = 'text'), + password TEXT CHECK (typeof(password) IN ('text', 'null')) + )`, + `INSERT INTO auth (id, secret, password) + VALUES + ('foo', 'secret', 'quux')`, + ], + }, + { + name: 'link', + statements: [ + `CREATE TABLE IF NOT EXISTS link ( + id TEXT NOT NULL PRIMARY KEY CHECK (typeof(id) = 'text'), + url TEXT NOT NULL UNIQUE CHECK (typeof(url) = 'text'), + created INTEGER NOT NULL DEFAULT ${EPOCH_NOW} CHECK (typeof(created) = 'integer'), + last_access INTEGER NOT NULL DEFAULT 0 CHECK (typeof(last_access) = 'integer'), + accesses INTEGER NOT NULL DEFAULT 0 CHECK (typeof(accesses) = 'integer'), + expires INTEGER CHECK (typeof(expires) IN ('integer', 'null')), + auth_token TEXT CHECK (typeof(auth_token) IN ('text', 'null')), + is_special INTEGER NOT NULL DEFAULT 0 CHECK (is_special IN (0, 1)) + )`, + 'CREATE INDEX IF NOT EXISTS link_url_idx ON link(url)', + ], + }, + ]; + + tables.forEach((t) => { + const table = tableExists.get({ tableName: t.name }); + if (table === undefined) { + t.statements.forEach((s) => { + this.logger.info(_scope, 'creating table', { tableName: t.name }); + this.db.prepare(s).run(); + }); + } + }); + } + + _initStatements() { + this.statement = { + _beginImmediate: this.db.prepare('BEGIN IMMEDIATE'), + _commit: this.db.prepare('COMMIT'), + _rollback: this.db.prepare('ROLLBACK'), + getAuthById: this.db.prepare('SELECT * FROM auth WHERE id = :id'), + insertAuth: this.db.prepare('INSERT INTO auth (id, secret, password) VALUES (:id, :secret, :credential)'), + updateAuth: this.db.prepare('UPDATE auth SET password = :credential, secret = :secret WHERE id = :id'), + getLinkById: this.db.prepare('SELECT * FROM link WHERE id = :id'), + getLinkByUrl: this.db.prepare('SELECT * FROM link WHERE url = :url'), + upsertLink: this.db.prepare('INSERT INTO link (id, url, auth_token) VALUES (:id, :url, :authToken)'), + updateLink: this.db.prepare('UPDATE link SET url = :url WHERE id = :id'), + incrementLinkAccess: this.db.prepare(`UPDATE link SET accesses = accesses + 1, last_access = ${EPOCH_NOW} WHERE id = :id`), + expireLink: this.db.prepare('UPDATE link SET expires = :expires WHERE id = :id'), + linkGetAll: this.db.prepare('SELECT * FROM link'), + }; + } + + _optimize() { + const _scope = _fileScope('_optimize'); + this.logger.debug(_scope, 'called', {}); + const optimizations = this.db.prepare('SELECT * FROM pragma_optimize(0x03)').all(); + this.logger.debug(_scope, 'pragma preview', { optimizations }); + this.db.pragma('optimize'); + this.changesSinceLastOptimize = BigInt(0); + } + + _maybeOptimize() { + if (this.changesSinceLastOptimize >= this.optimizeAfterChanges) { + this._optimize(); + } + } + + _sqliteInfo(info) { + if (info.changes) { + this.changesSinceLastOptimize += BigInt(info.changes); + this._maybeOptimize(); + } + return { + changes: Number(info.changes), + lastInsertRowid: Number(info.lastInsertRowid), + }; + } + + static _deOphidiate(row) { + return row && Object.keys(row).reduce((snakelessRow, k) => Object.assign(snakelessRow, { + // eslint-disable-next-line security/detect-object-injection + [BaseDatabase._camelfy(k)]: row[k], + }), {}); + } + + + async context(fn) { + const dbCtx = this.db; + return await fn(dbCtx); + } + + async transaction(dbCtx, fn, ...rest) { + dbCtx = dbCtx || this.db; + try { + this.statement._beginImmediate.run(); + const result = await fn(dbCtx, ...rest); + this.statement._commit.run(); + return result; + } finally { + if (this.db.inTransaction) { + this.statement._rollback.run(); + } + } + } + + async getAuthById(dbCtx, id) { + const _scope = _fileScope('getAuthById'); + this.logger.debug(_scope, 'called', { id }); + + let auth; + auth = this.statement.getAuthById.get({ id }); + auth = SQLiteDatabase._deOphidiate(auth); + + this.logger.debug(_scope, 'get', { auth }); + return auth; + } + + async upsertAuth(dbCtx, id, secret, credential) { + const _scope = _fileScope('upsertAuthCredential'); + this.logger.debug(_scope, 'called', { id }); + + let info; + try { + info = this.statement.insertAuth.run({ id, secret, credential }); + } catch (e) { + switch (e.code) { + case 'SQLITE_CONSTRAINT_UNIQUE': + case 'SQLITE_CONSTRAINT_PRIMARYKEY': { + this.logger.debug(_scope, 'updating existing auth', { id }); + info = this.statement.updateAuth.run({ id, secret, credential }); + break; + } + + default: { + this.logger.error(_scope, 'failed to upsert auth credential', { error: e, id }); + throw e; + } + } + } + this.logger.debug(_scope, 'run', { info }); + if (info.changes != 1) { + this.logger.error(_scope, 'failed to upsert auth credential', { id, info }); + throw new DBErrors.UnexpectedResult(); + } + + return this._sqliteInfo(info); + } + + async upsertLink(dbCtx, id, url, authToken) { + const _scope = _fileScope('upsertLink'); + this.logger.debug(_scope, 'called', { id, url }); + + let info; + try { + info = this.statement.upsertLink.run({ id, url, authToken }); + } catch (e) { + switch (e.code) { + case 'SQLITE_CONSTRAINT_UNIQUE': + case 'SQLITE_CONSTRAINT_PRIMARYKEY': { + this.logger.debug(_scope, 'updating existing id', { id, url }); + info = this.statement.updateLink.run({ id, url }); + break; + } + + default: { + this.logger.error(_scope, 'failed to insert link', { error: e, id, url }); + throw e; + } + } + } + this.logger.debug(_scope, 'run', { info }); + if (info.changes != 1) { + this.logger.error(_scope, 'failed to insert link', { id, url, info }); + throw new DBErrors.UnexpectedResult(); + } + + return this._sqliteInfo(info); + } + + static _linkToNative(link) { + return link && { + id: link.id, + url: link.url, + created: Number(link.created), + lastAccess: Number(link.lastAccess), + accesses: Number(link.accesses), + expires: ('expires' in link) ? Number(link.expires) : undefined, + authToken: link.authToken, + isSpecial: !! link.isSpecial, + }; + } + + async getLinkById(dbCtx, id) { + const _scope = _fileScope('getLinkById'); + this.logger.debug(_scope, 'called', { id }); + + let link = this.statement.getLinkById.get({ id }); + link = SQLiteDatabase._deOphidiate(link); + + this.logger.debug(_scope, 'get', { link }); + return SQLiteDatabase._linkToNative(link); + } + + async getLinkByUrl(dbCtx, url) { + const _scope = _fileScope('getLinkByUrl'); + this.logger.debug(_scope, 'called', { url }); + + let link = this.statement.getLinkByUrl.get({ url }); + link = SQLiteDatabase._deOphidiate(link); + + this.logger.debug(_scope, 'get', { link }); + return SQLiteDatabase._linkToNative(link); + } + + async accessLink(dbCtx, id) { + const _scope = _fileScope('accessLink'); + this.logger.debug(_scope, 'called', { id }); + + let link = this.statement.getLinkById.get({ id }); + link = SQLiteDatabase._deOphidiate(link); + + this.logger.debug(_scope, 'get', { id, link }); + + if (link) { + const info = this.statement.incrementLinkAccess.run({ id }); + this.logger.debug(_scope, 'increment', { id, info }); + if (info.changes != 1) { + this.logger.error(_scope, 'failed to increment link access', { id, link, info }); + throw new DBErrors.UnexpectedResult(); + } + } + return SQLiteDatabase._linkToNative(link); + } + + async expireLink(dbCtx, id, expires) { + const _scope = _fileScope('expireLink'); + this.logger.debug(_scope, 'called', { id }); + + const info = this.statement.expireLink.run({ id, expires }); + if (info.changes != 1) { + throw new DBErrors.UnexpectedResult(); + } + return this._sqliteInfo(info); + } + + // eslint-disable-next-line no-unused-vars + async getAllLinks(dbCtx) { + const _scope = _fileScope('getAllLinks'); + this.logger.debug(_scope, 'called', { }); + + let links; + links = this.statement.linkGetAll.all({}); + links = links.map((l) => SQLiteDatabase._deOphidiate(l)); + this.logger.debug(_scope, 'get', { links }); + links = links.map((l) => SQLiteDatabase._linkToNative(l)); + return links; + } + +} + +module.exports = SQLiteDatabase; diff --git a/src/db/sqlite/index.js b/src/db/sqlite/index.js index e1ce14d..aed3110 100644 --- a/src/db/sqlite/index.js +++ b/src/db/sqlite/index.js @@ -1,327 +1,240 @@ 'use strict'; -const SQLite = require('better-sqlite3'); -const BaseDatabase = require('../base'); -const common = require('../../common'); -const DBErrors = require('../errors'); - -const _fileScope = common.fileScope(__filename); - -const defaultOptions = { - connectionString: '', - optimizeAfterChanges: 1000, - queryLogLevel: 'debug', +const { SQLiteCreator, Errors: { UnexpectedResult } } = require('@squeep/db-helper'); +const { fileScope } = require('@squeep/log-helper'); +const Abstract = require('../abstract'); +const assert = require('node:assert'); // temporary validations during db interface migration + +const _fileScope = fileScope(__filename); + +const schemaVersionsSupported = { + min: { + major: 1, + minor: 0, + patch: 0, + }, + max: { + major: 1, + minor: 0, + patch: 1, + }, }; -const EPOCH_NOW = '(strftime(\'%s\', \'now\'))'; - -class SQLiteDatabase extends BaseDatabase { - constructor(logger, options) { - const _scope = _fileScope('constructor'); - - super(logger); - common.setOptions(this, defaultOptions, options); - - this.dbFilename = this.connectionString.slice('sqlite://'.length) || ':memory:'; - - this.logger.debug(_scope, 'connecting', { dbFilename: this.dbFilename }); - - this.db = new SQLite(this.dbFilename, { - verbose: (query) => this.queryLogLevel && this.logger[this.queryLogLevel](_fileScope('statement'), 'executing', { query }), - }); - - this.changesSinceLastOptimize = BigInt(0); - - this.db.pragma('foreign_keys = on'); - this.db.pragma('journal_mode = WAL'); - this.db.defaultSafeIntegers(true); +class DatabaseSQLite extends SQLiteCreator(Abstract) { - this._initTables(); - this._initStatements(); + constructor(...args) { + super(...args); + /* istanbul ignore next */ + if (!this._isProduction) { + this._tableNames.push( + 'link', + 'auth', + ); + } } - _initTables() { - const _scope = _fileScope('_initTables'); - const tableExists = this.db.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=:tableName').pluck(true); - - const tables = [ - { - name: 'auth', - statements: [ - `CREATE TABLE IF NOT EXISTS auth ( - id TEXT NOT NULL PRIMARY KEY CHECK (typeof(id) = 'text'), - secret TEXT NOT NULL CHECK (typeof(secret) = 'text'), - password TEXT CHECK (typeof(password) IN ('text', 'null')) - )`, - `INSERT INTO auth (id, secret, password) - VALUES - ('foo', 'secret', 'quux')`, - ], - }, - { - name: 'link', - statements: [ - `CREATE TABLE IF NOT EXISTS link ( - id TEXT NOT NULL PRIMARY KEY CHECK (typeof(id) = 'text'), - url TEXT NOT NULL UNIQUE CHECK (typeof(url) = 'text'), - created INTEGER NOT NULL DEFAULT ${EPOCH_NOW} CHECK (typeof(created) = 'integer'), - last_access INTEGER NOT NULL DEFAULT 0 CHECK (typeof(last_access) = 'integer'), - accesses INTEGER NOT NULL DEFAULT 0 CHECK (typeof(accesses) = 'integer'), - expires INTEGER CHECK (typeof(expires) IN ('integer', 'null')), - auth_token TEXT CHECK (typeof(auth_token) IN ('text', 'null')), - is_special INTEGER NOT NULL DEFAULT 0 CHECK (is_special IN (0, 1)) - )`, - 'CREATE INDEX IF NOT EXISTS link_url_idx ON link(url)', - ], - }, - ]; - - tables.forEach((t) => { - const table = tableExists.get({ tableName: t.name }); - if (table === undefined) { - t.statements.forEach((s) => { - this.logger.info(_scope, 'creating table', { tableName: t.name }); - this.db.prepare(s).run(); - }); - } - }); + // eslint-disable-next-line class-methods-use-this + get schemaVersionsSupported() { + return schemaVersionsSupported; } - _initStatements() { - this.statement = { - _beginImmediate: this.db.prepare('BEGIN IMMEDIATE'), - _commit: this.db.prepare('COMMIT'), - _rollback: this.db.prepare('ROLLBACK'), - getAuthById: this.db.prepare('SELECT * FROM auth WHERE id = :id'), - insertAuth: this.db.prepare('INSERT INTO auth (id, secret, password) VALUES (:id, :secret, :credential)'), - updateAuth: this.db.prepare('UPDATE auth SET password = :credential, secret = :secret WHERE id = :id'), - getLinkById: this.db.prepare('SELECT * FROM link WHERE id = :id'), - getLinkByUrl: this.db.prepare('SELECT * FROM link WHERE url = :url'), - insertLink: this.db.prepare('INSERT INTO link (id, url, auth_token) VALUES (:id, :url, :authToken)'), - updateLink: this.db.prepare('UPDATE link SET url = :url WHERE id = :id'), - incrementLinkAccess: this.db.prepare(`UPDATE link SET accesses = accesses + 1, last_access = ${EPOCH_NOW} WHERE id = :id`), - expireLink: this.db.prepare('UPDATE link SET expires = :expires WHERE id = :id'), - linkGetAll: this.db.prepare('SELECT * FROM link'), - }; - } - _optimize() { - const _scope = _fileScope('_optimize'); - this.logger.debug(_scope, 'called', {}); - const optimizations = this.db.prepare('SELECT * FROM pragma_optimize(0x03)').all(); - this.logger.debug(_scope, 'pragma preview', { optimizations }); - this.db.pragma('optimize'); - this.changesSinceLastOptimize = BigInt(0); + async initialize(sqlPath = __dirname, applyMigrations = true) { + /* istanbul ignore next */ + await super.initialize(sqlPath, applyMigrations); } - _maybeOptimize() { - if (this.changesSinceLastOptimize >= this.optimizeAfterChanges) { - this._optimize(); - } - } - _sqliteInfo(info) { - if (info.changes) { - this.changesSinceLastOptimize += BigInt(info.changes); - this._maybeOptimize(); - } - return { - changes: Number(info.changes), - lastInsertRowid: Number(info.lastInsertRowid), + static _authDbToNative(auth) { + return auth && { + ...auth, }; } - static _deOphidiate(row) { - return row && Object.keys(row).reduce((snakelessRow, k) => Object.assign(snakelessRow, { - // eslint-disable-next-line security/detect-object-injection - [BaseDatabase._camelfy(k)]: row[k], - }), {}); - } - - async context(fn) { - const dbCtx = this.db; - return await fn(dbCtx); + static _linkDbToNative(link) { + return link && { + ...link, + created: this._epochToDate(link.created), + lastAccess: (link.lastAccess > 0) ? this._epochToDate(link.lastAccess) : -Infinity, + accesses: Number(link.accesses), + expires: (link.expires != null) ? this._epochToDate(link.expires) : null, + isSpecial: !! link.isSpecial, + }; } - async transaction(dbCtx, fn, ...rest) { - dbCtx = dbCtx || this.db; - try { - this.statement._beginImmediate.run(); - const result = await fn(dbCtx, ...rest); - this.statement._commit.run(); - return result; - } finally { - if (this.db.inTransaction) { - this.statement._rollback.run(); - } - } - } async getAuthById(dbCtx, id) { const _scope = _fileScope('getAuthById'); this.logger.debug(_scope, 'called', { id }); - - let auth; - auth = this.statement.getAuthById.get({ id }); - auth = SQLiteDatabase._deOphidiate(auth); - this.logger.debug(_scope, 'get', { auth }); - return auth; + try { + const auth = this.statement.authGetById.get({ id }); + return this.constructor._authDbToNative(auth); + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id }); + throw err; + } + } + async upsertAuth(dbCtx, id, secret, credential) { - const _scope = _fileScope('upsertAuthCredential'); + const _scope = _fileScope('upsertAuth'); this.logger.debug(_scope, 'called', { id }); - let info; try { - info = this.statement.insertAuth.run({ id, secret, credential }); - } catch (e) { - switch (e.code) { - case 'SQLITE_CONSTRAINT_UNIQUE': - case 'SQLITE_CONSTRAINT_PRIMARYKEY': { - this.logger.debug(_scope, 'updating existing auth', { id }); - info = this.statement.updateAuth.run({ id, secret, credential }); - break; - } - - default: { - this.logger.error(_scope, 'failed to upsert auth credential', { error: e, id }); - throw e; + return await this.transaction(dbCtx, async () => { + try { + const result = this.statement.authInsert.run({ id, secret, credential }); + if (result.changes != 1) { + this.logger.error(_scope, 'failed to insert auth entry', { result, id }); + throw new UnexpectedResult('failed to insert auth'); + } + } catch (e) { + switch (e.code) { + case 'SQLITE_CONSTRAINT_UNIQUE': + case 'SQLITE_CONSTRAINT_PRIMARYKEY': { + const result = this.statement.authUpdate.run({ id, secret, credential }); + if (result.changes != 1) { + this.logger.error(_scope, 'failed to update auth entry', { result, id }); + throw new UnexpectedResult('failed to update auth'); + } + break; + } + + default: + throw e; + } } - } - } - this.logger.debug(_scope, 'run', { info }); - if (info.changes != 1) { - this.logger.error(_scope, 'failed to upsert auth credential', { id, info }); - throw new DBErrors.UnexpectedResult(); + }); // tx + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id }); + throw err; } - - return this._sqliteInfo(info); } - async insertLink(dbCtx, id, url, authToken) { - const _scope = _fileScope('insertLink'); + + async upsertLink(dbCtx, id, url, authToken) { + const _scope = _fileScope('upsertLink'); this.logger.debug(_scope, 'called', { id, url }); - let info; try { - info = this.statement.insertLink.run({ id, url, authToken }); - } catch (e) { - switch (e.code) { - case 'SQLITE_CONSTRAINT_UNIQUE': - case 'SQLITE_CONSTRAINT_PRIMARYKEY': { - this.logger.debug(_scope, 'updating existing id', { id, url }); - info = this.statement.updateLink.run({ id, url }); - break; + return await this.transaction(dbCtx, async () => { + try { + const result = this.statement.linkInsert.run({ id, url, authToken }); + if (result.changes != 1) { + this.logger.error(_scope, 'failed to insert link', { id, url, result }); + throw new UnexpectedResult('failed to insert link'); + } + } catch (e) { + switch (e.code) { + case 'SQLITE_CONSTRAINT_UNIQUE': + case 'SQLITE_CONSTRAINT_PRIMARYKEY': { + const result = this.statement.linkUpdate.run({ id, url }); + if (result.changes != 1) { + this.logger.error(_scope, 'failed to update link', { id, url, result }); + throw new UnexpectedResult('failed to update link'); + } + break; + } + + default: + throw e; + } } - - default: { - this.logger.error(_scope, 'failed to insert link', { error: e, id, url }); - throw e; - } - } + }); // tx + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id, url }); + throw err; } - this.logger.debug(_scope, 'run', { info }); - if (info.changes != 1) { - this.logger.error(_scope, 'failed to insert link', { id, url, info }); - throw new DBErrors.UnexpectedResult(); - } - - return this._sqliteInfo(info); } - static _linkToNative(link) { - return link && { - id: link.id, - url: link.url, - created: Number(link.created), - lastAccess: Number(link.lastAccess), - accesses: Number(link.accesses), - expires: ('expires' in link) ? Number(link.expires) : undefined, - authToken: link.authToken, - isSpecial: !! link.isSpecial, - }; - } async getLinkById(dbCtx, id) { const _scope = _fileScope('getLinkById'); this.logger.debug(_scope, 'called', { id }); - let link = this.statement.getLinkById.get({ id }); - link = SQLiteDatabase._deOphidiate(link); - - this.logger.debug(_scope, 'get', { link }); - return SQLiteDatabase._linkToNative(link); + try { + const link = this.statement.linkGetById.get({ id }); + return this.constructor._linkDbToNative(link); + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id }); + throw err; + } } + async getLinkByUrl(dbCtx, url) { const _scope = _fileScope('getLinkByUrl'); this.logger.debug(_scope, 'called', { url }); - let link = this.statement.getLinkByUrl.get({ url }); - link = SQLiteDatabase._deOphidiate(link); - - this.logger.debug(_scope, 'get', { link }); - return SQLiteDatabase._linkToNative(link); + try { + const link = this.statement.linkGetByUrl.get({ url }); + return this.constructor._linkDbToNative(link); + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, url }); + throw err; + } } + async accessLink(dbCtx, id) { const _scope = _fileScope('accessLink'); this.logger.debug(_scope, 'called', { id }); - let link = this.statement.getLinkById.get({ id }); - link = SQLiteDatabase._deOphidiate(link); - - this.logger.debug(_scope, 'get', { id, link }); - - if (link) { - const info = this.statement.incrementLinkAccess.run({ id }); - this.logger.debug(_scope, 'increment', { id, info }); - if (info.changes != 1) { - this.logger.error(_scope, 'failed to increment link access', { id, link, info }); - throw new DBErrors.UnexpectedResult(); + return await this.transaction(dbCtx, async () => { + try { + const link = this.statement.linkGetById.get({ id }); + if (link) { + const result = this.statement.linkAccess.run({ id }); + if (result.changes != 1) { + this.logger.error(_scope, 'failed to increment link access', { id, link, result }); + throw new UnexpectedResult('failed to increment link access'); + } + } + return this.constructor._linkDbToNative(link); + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id }); + throw err; } - } - return SQLiteDatabase._linkToNative(link); + }); // tx } + async expireLink(dbCtx, id, expires) { const _scope = _fileScope('expireLink'); - this.logger.debug(_scope, 'called', { id }); - - const info = this.statement.expireLink.run({ id, expires }); - if (info.changes != 1) { - throw new DBErrors.UnexpectedResult(); - } - return this._sqliteInfo(info); - } + this.logger.debug(_scope, 'called', { id, expires }); - async updateLink(dbCtx, id, url) { - const _scope = _fileScope('updateLink'); - this.logger.debug(_scope, 'called', { id, url }); + assert(expires instanceof Date); - const info = this.statement.updateLink.run({ id, url }); - if (info.changes != 1) { - throw new DBErrors.UnexpectedResult(); - } - return this._sqliteInfo(info); + try { + const result = this.statement.linkExpire.run({ id, expires: this.constructor._dateToEpoch(expires) }); + if (result.changes != 1) { + throw new UnexpectedResult('failed to update link expiration'); + } + } catch (err) { + this.logger.error(_scope, 'failed', { error: err, id, expires }); + throw err; + } } + // eslint-disable-next-line no-unused-vars async getAllLinks(dbCtx) { const _scope = _fileScope('getAllLinks'); this.logger.debug(_scope, 'called', { }); - - let links; - links = this.statement.linkGetAll.all({}); - links = links.map((l) => SQLiteDatabase._deOphidiate(l)); - this.logger.debug(_scope, 'get', { links }); - links = links.map((l) => SQLiteDatabase._linkToNative(l)); - return links; + + try { + const links = this.statement.linkGetAll.all({}); + return links.map((l) => this.constructor._linkDbToNative(l)); + } catch (err) { + this.logger.error(_scope, 'failed', { error: err }); + throw err; + } } + } -module.exports = SQLiteDatabase; +module.exports = DatabaseSQLite; \ No newline at end of file diff --git a/src/db/sqlite/sql/auth-get-by-id.sql b/src/db/sqlite/sql/auth-get-by-id.sql new file mode 100644 index 0000000..a579d16 --- /dev/null +++ b/src/db/sqlite/sql/auth-get-by-id.sql @@ -0,0 +1 @@ +SELECT * FROM auth WHERE id = :id diff --git a/src/db/sqlite/sql/auth-insert.sql b/src/db/sqlite/sql/auth-insert.sql new file mode 100644 index 0000000..d315036 --- /dev/null +++ b/src/db/sqlite/sql/auth-insert.sql @@ -0,0 +1 @@ +INSERT INTO auth (id, secret, password) VALUES (:id, :secret, :credential) diff --git a/src/db/sqlite/sql/auth-update.sql b/src/db/sqlite/sql/auth-update.sql new file mode 100644 index 0000000..d44fbfb --- /dev/null +++ b/src/db/sqlite/sql/auth-update.sql @@ -0,0 +1 @@ +UPDATE auth SET password = :credential, secret = :secret WHERE id = :id diff --git a/src/db/sqlite/sql/link-access.sql b/src/db/sqlite/sql/link-access.sql new file mode 100644 index 0000000..e2f0ab4 --- /dev/null +++ b/src/db/sqlite/sql/link-access.sql @@ -0,0 +1 @@ +UPDATE link SET accesses = accesses + 1, last_access = (strftime('%s', 'now')) WHERE id = :id diff --git a/src/db/sqlite/sql/link-expire.sql b/src/db/sqlite/sql/link-expire.sql new file mode 100644 index 0000000..eef3dd7 --- /dev/null +++ b/src/db/sqlite/sql/link-expire.sql @@ -0,0 +1 @@ +UPDATE link SET expires = :expires WHERE id = :id diff --git a/src/db/sqlite/sql/link-get-all.sql b/src/db/sqlite/sql/link-get-all.sql new file mode 100644 index 0000000..1594a5d --- /dev/null +++ b/src/db/sqlite/sql/link-get-all.sql @@ -0,0 +1 @@ +SELECT * FROM link diff --git a/src/db/sqlite/sql/link-get-by-id.sql b/src/db/sqlite/sql/link-get-by-id.sql new file mode 100644 index 0000000..7940dea --- /dev/null +++ b/src/db/sqlite/sql/link-get-by-id.sql @@ -0,0 +1 @@ +SELECT * FROM link WHERE id = :id diff --git a/src/db/sqlite/sql/link-get-by-url.sql b/src/db/sqlite/sql/link-get-by-url.sql new file mode 100644 index 0000000..301a3cc --- /dev/null +++ b/src/db/sqlite/sql/link-get-by-url.sql @@ -0,0 +1 @@ +SELECT * FROM link WHERE url = :url diff --git a/src/db/sqlite/sql/link-insert.sql b/src/db/sqlite/sql/link-insert.sql new file mode 100644 index 0000000..77a5808 --- /dev/null +++ b/src/db/sqlite/sql/link-insert.sql @@ -0,0 +1 @@ +INSERT INTO link (id, url, auth_token) VALUES (:id, :url, :authToken) diff --git a/src/db/sqlite/sql/link-update.sql b/src/db/sqlite/sql/link-update.sql new file mode 100644 index 0000000..0d44e6a --- /dev/null +++ b/src/db/sqlite/sql/link-update.sql @@ -0,0 +1 @@ +UPDATE link SET url = :url WHERE id = :id diff --git a/src/db/sqlite/sql/schema/1.0.0/apply.sql b/src/db/sqlite/sql/schema/1.0.0/apply.sql new file mode 100644 index 0000000..43d09bf --- /dev/null +++ b/src/db/sqlite/sql/schema/1.0.0/apply.sql @@ -0,0 +1,22 @@ + +CREATE TABLE IF NOT EXISTS auth ( + id TEXT NOT NULL PRIMARY KEY CHECK (typeof(id) = 'text'), + secret TEXT NOT NULL CHECK (typeof(secret) = 'text'), + password TEXT CHECK (typeof(password) IN ('text', 'null')) +); + +CREATE TABLE IF NOT EXISTS link ( + id TEXT NOT NULL PRIMARY KEY CHECK (typeof(id) = 'text'), + url TEXT NOT NULL UNIQUE CHECK (typeof(url) = 'text'), + created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(created) = 'integer'), + last_access INTEGER NOT NULL DEFAULT 0 CHECK (typeof(last_access) = 'integer'), + accesses INTEGER NOT NULL DEFAULT 0 CHECK (typeof(accesses) = 'integer'), + expires INTEGER CHECK (typeof(expires) IN ('integer', 'null')), + auth_token TEXT CHECK (typeof(auth_token) IN ('text', 'null')) +); + +INSERT INTO link (id, url) VALUES + ('static', '/static/index.html'), + ('favicon.ico', '/static/favicon.ico'), + ('robots.txt', '/static/robots.txt') +; diff --git a/src/db/sqlite/sql/schema/1.0.0/revert.sql b/src/db/sqlite/sql/schema/1.0.0/revert.sql new file mode 100644 index 0000000..a29edc5 --- /dev/null +++ b/src/db/sqlite/sql/schema/1.0.0/revert.sql @@ -0,0 +1,6 @@ +BEGIN; + + DROP TABLE link; + DROP TABLE auth; + +COMMIT; diff --git a/src/db/sqlite/sql/schema/1.0.1/apply.sql b/src/db/sqlite/sql/schema/1.0.1/apply.sql new file mode 100644 index 0000000..488692b --- /dev/null +++ b/src/db/sqlite/sql/schema/1.0.1/apply.sql @@ -0,0 +1,12 @@ +ALTER TABLE link + ADD COLUMN is_special BOOLEAN NOT NULL DEFAULT false +; + +UPDATE link + SET is_special = true +WHERE + url IN ( + '/static/robots.txt', + '/static/index.html', + '/static/favicon.ico' + ); diff --git a/src/db/sqlite/sql/schema/1.0.1/revert.sql b/src/db/sqlite/sql/schema/1.0.1/revert.sql new file mode 100644 index 0000000..e7b3c22 --- /dev/null +++ b/src/db/sqlite/sql/schema/1.0.1/revert.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE link + DROP COLUMN is_special + +COMMIT; diff --git a/src/manager.js b/src/manager.js index c510f18..8807f53 100644 --- a/src/manager.js +++ b/src/manager.js @@ -6,14 +6,15 @@ const Enum = require('./enum'); const { ResponseError, ServeStaticFile, SlugGeneratorExhausted } = require('./errors'); const { makeSlugGenerator } = require('./slug'); const Template = require('./template'); +const { fileScope } = require('@squeep/log-helper'); -const _fileScope = common.fileScope(__filename); +const _fileScope = fileScope(__filename); const defaultOptions = { pageTitle: require('../package.json').name, logoUrl: '/static/muRL.png', selfBaseUrl: '', - staticDirectory: '/static/', + staticRoutePrefix: '/static/', }; /** @@ -121,7 +122,7 @@ class Manager { const _scope = _fileScope('_validateContextURL'); // Admin is allowed to create locally served entries. - if (ctx?.sourceLink?.startsWith(this.staticDirectory) + if (ctx?.sourceLink?.startsWith(this.staticRoutePrefix) && ctx.authenticationId) { return; } @@ -162,11 +163,11 @@ class Manager { throw new ResponseError(Enum.ErrorResponse.Gone); } - if (link.url.startsWith(this.staticDirectory)) { + if (link.url.startsWith(this.staticRoutePrefix)) { /* This is only a little awkward: throw back up to service to let the * dingus handle the static file serving. */ - const fileName = link.url.slice(this.staticDirectory.length); + const fileName = link.url.slice(this.staticRoutePrefix.length); this.logger.info(_scope, 'static link', { link, ctx }); throw new ServeStaticFile(fileName); } @@ -189,7 +190,6 @@ class Manager { const _scope = _fileScope('postRoot'); this.logger.debug(_scope, 'called', { ctx }); - let content; ctx.sourceLink = ctx.parsedBody.url; if (!ctx.sourceLink) { throw new ResponseError(Enum.ErrorResponse.BadRequest, 'missing required parameter'); @@ -210,11 +210,11 @@ class Manager { res.statusCode = 201; // Created const id = await this._getNewIdentifier(txCtx, ctx.sourceLink); ctx.authToken = await Authenticator.generateToken(); - await this.db.insertLink(txCtx, id, ctx.sourceLink, ctx.authToken); + await this.db.upsertLink(txCtx, id, ctx.sourceLink, ctx.authToken); ctx.createdLink = `${this.selfBaseUrl}/${id}`; this.logger.info(_scope, 'created new link', { id, ctx }); } - content = this.rootContent(ctx); + const content = this.rootContent(ctx); return res.end(content); }); }); @@ -243,21 +243,21 @@ class Manager { // To create a specific link, need admin authentication. if (!ctx.authenticationId) { this.logger.debug(_scope, 'cannot put without valid credentials', { ctx }); - throw new ResponseError(Enum.ErrorResponse.Forbidden); + throw new ResponseError(Enum.ErrorResponse.Forbidden, 'Supplied credentials do not allow PUT creation.'); } res.statusCode = 201; // Created ctx.authToken = await Authenticator.generateToken(); - this.db.insertLink(txCtx, ctx.params.id, ctx.sourceLink, ctx.authToken); + await this.db.upsertLink(txCtx, ctx.params.id, ctx.sourceLink, ctx.authToken); ctx.createdLink = `${this.selfBaseUrl}/${ctx.params.id}`; this.logger.info(_scope, 'created new link', { ctx }); } else { // Update link - await this.db.insertLink(txCtx, existingLink.id, ctx.sourceLink, existingLink.token); + await this.db.upsertLink(txCtx, existingLink.id, ctx.sourceLink, existingLink.token); ctx.createdLink = `${this.selfBaseUrl}/${existingLink.id}`; this.logger.info(_scope, 'updated link', { ctx }); } - }); + }); // tx }); const content = this.rootContent(ctx); @@ -275,6 +275,7 @@ class Manager { const _scope = _fileScope('deleteById'); this.logger.debug(_scope, 'called', { ctx }); + const now = new Date(); let link; await this.db.context(async (dbCtx) => { await this.db.transaction(dbCtx, async (txCtx) => { @@ -283,13 +284,13 @@ class Manager { this.logger.debug(_scope, 'no such id', { ctx }); throw new ResponseError(Enum.ErrorResponse.InvalidLinkId); } - if (link.expires && link.expires < Date.now()) { + if (link.expires && link.expires < now) { + this.logger.debug(_scope, 'already expired', { ctx }); res.statusCode = 304; // Not Modified return; } - const when = Date.now() / 1000; - await this.db.expireLink(txCtx, ctx.params.id, when); - this.logger.info(_scope, 'expired link', { ctx, when }); + await this.db.expireLink(txCtx, ctx.params.id, now); + this.logger.info(_scope, 'expired link', { ctx, now }); res.statusCode = 204; // No Content }); }); diff --git a/src/service.js b/src/service.js index 18d5736..6e26700 100644 --- a/src/service.js +++ b/src/service.js @@ -12,12 +12,13 @@ const Enum = require('./enum'); const { ServeStaticFile } = require('./errors'); const Authenticator = require('./authenticator'); const Manager = require('./manager'); +const { fileScope } = require('@squeep/log-helper'); -const _fileScope = common.fileScope(__filename); +const _fileScope = fileScope(__filename); const defaultOptions = { ignoreTrailingSlash: true, - staticDirectory: path.join(__dirname, '..', 'static'), + staticPath: path.join(__dirname, '..', 'static'), createRequiresAuth: false, proxyPrefix: '', }; @@ -35,27 +36,30 @@ class Service extends Dingus { this.authenticator = new Authenticator(logger, db, options?.authenticator); this._postRootAuth = this.authenticator[this.createRequiresAuth ? 'required' : 'optional'].bind(this.authenticator); - this.manager = new Manager(logger, db, options?.manager); + this.manager = new Manager(logger, db, options); this.responseTypes.push( // 'other/handled-type', ); - this.on('POST', '/', this.handlerPostRoot.bind(this)); - this.on(['GET', 'HEAD'], '/', this.handlerGetRoot.bind(this)); - this.on(['GET', 'HEAD'], '/:id', this.handlerGetId.bind(this)); - this.on('DELETE', '/:id', this.handlerDeleteId.bind(this)); - this.on('PUT', '/:id', this.handlerPutId.bind(this)); - this.on(['GET', 'HEAD'], '/:id/info', this.handlerGetIdInfo.bind(this)); - this.on(['GET', 'HEAD'], '/static/:file', this.handlerGetStatic.bind(this)); - this.on(['GET', 'HEAD'], '/admin/report', this.handlerGetAdminReport.bind(this)); + this.on(['GET'], '/', 'root', this.handlerGetRoot.bind(this)); + this.on(['POST'], '/', this.handlerPostRoot.bind(this)); + + this.on(['GET'], '/:id', 'murl', this.handlerGetId.bind(this)); + this.on(['DELETE'], '/:id', this.handlerDeleteId.bind(this)); + this.on(['PUT'], '/:id', this.handlerPutId.bind(this)); + + this.on(['GET'], '/:id/info', this.handlerGetIdInfo.bind(this)); + this.on(['GET'], '/admin/report', this.handlerGetAdminReport.bind(this)); + + this.defineStandardStaticRoutes(options); } /** * Return a content-type appropriate rendering of an errorResponse object. * @param {string} contentType content type of response - * @param {Enum.ResponseError|Exception} err error + * @param {Enum.ResponseError|Error} err error * @returns {string} rendered error */ renderError(contentType, err) { @@ -99,6 +103,7 @@ class Service extends Dingus { Dingus.setEndBodyHandler(req, res, ctx, this._endHandler.bind(this)); const logObject = this.asyncLocalStorage.getStore(); + /* istanbul ignore next */ if (logObject) { logObject.requestId = ctx.requestId; delete ctx.requestId; @@ -116,6 +121,7 @@ class Service extends Dingus { this.authenticator.signResponse(req, res, ctx); } + /** * @param {http.ClientRequest} req request * @param {http.ServerResponse} res resopnse @@ -177,9 +183,10 @@ class Service extends Dingus { try { await this.manager.getById(res, ctx); } catch (e) { + // Manager can throw ServeStaticFile when id maps to a static file. if (e instanceof ServeStaticFile && e.file) { - return await this.serveFile(req, res, ctx, this.staticDirectory, e.file); + return await this.serveFile(req, res, ctx, this.staticPath, e.file); } throw e; } @@ -240,26 +247,6 @@ class Service extends Dingus { } - /** - * @param {http.ClientRequest} req request - * @param {http.ServerResponse} res response - * @param {object} ctx conetxt - * @returns {Promise} - */ - async handlerGetStatic(req, res, ctx) { - const _scope = _fileScope('handlerGetStatic'); - this.logger.debug(_scope, 'called', { req, ctx }); - - Dingus.setHeadHandler(req, res, ctx); - - // We set default response type to handle any errors, but will override for actual static content type. - this.setResponseType(this.responseTypes, req, res, ctx); - this.authenticator.optional(req, res, ctx); - - await this.serveFile(req, res, ctx, this.staticDirectory, ctx.params.file); - } - - /** * @param {http.ClientRequest} req request * @param {http.ServerResponse} res response diff --git a/src/template/info-html.js b/src/template/info-html.js index d18a2c5..399fbb9 100644 --- a/src/template/info-html.js +++ b/src/template/info-html.js @@ -2,24 +2,15 @@ const { TemplateHelper: th } = require('@squeep/html-template-helper'); -const toDateString = (ts) => { - try { - return (ts && isFinite(ts)) ? (new Date(ts * 1000).toISOString()) : 'never'; - } catch (e) { // eslint-disable-line no-unused-vars - return `(whatever '${ts.toString()}' is)`; - } -}; - - /** * * @param {object} details link details * @returns {string} HTML details section */ function renderDetailsSection(details) { - const created = toDateString(details.created); - const lastAccess = toDateString(details.lastAccess); - const expires = details.expires ? toDateString(details.expires) : ''; + const created = th.dateFormat(details.created); + const lastAccess = th.dateFormat(details.lastAccess, 'never', 'never', 'never'); + const expires = th.dateFormat(details.expires, 'never', 'forever', 'never'); return `
\t diff --git a/src/template/report-html.js b/src/template/report-html.js index 9c995d9..7734d69 100644 --- a/src/template/report-html.js +++ b/src/template/report-html.js @@ -2,15 +2,6 @@ const { TemplateHelper: th } = require('@squeep/html-template-helper'); -const toDateString = (ts) => { - try { - return (ts && isFinite(ts)) ? (new Date(ts * 1000).toISOString()) : 'never'; - } catch (e) { // eslint-disable-line no-unused-vars - return `(whatever '${ts.toString()}' is)`; - } -}; - - /** * * @returns {string} HTML table header row @@ -33,9 +24,9 @@ function renderLinkRow(link) { \t${link.id} \t${link.url} \t${link.accesses} -\t${toDateString(link.created)} -\t${toDateString(link.expires)} -\t${toDateString(link.lastAccess)} +\t${th.dateFormat(link.created)} +\t${th.dateFormat(link.expires, 'never', 'forever', 'never')} +\t${th.dateFormat(link.lastAccess, 'never', 'never', 'never')} `; } diff --git a/test/src/db/abstract.js b/test/src/db/abstract.js new file mode 100644 index 0000000..e0da09c --- /dev/null +++ b/test/src/db/abstract.js @@ -0,0 +1,38 @@ +'use strict'; + +const assert = require('node:assert'); +const sinon = require('sinon'); + +const { StubLogger } = require('@squeep/test-helper'); +const { test: { interfaceMethods }, Errors: { NotImplemented } } = require('@squeep/db-helper'); +const Abstract = require('../../../src/db/abstract'); + +describe('Abstract', function () { + let db, stubLogger; + beforeEach(function () { + stubLogger = new StubLogger(sinon); + db = new Abstract(stubLogger); + }); + afterEach(function () { + sinon.restore(); + }); + + it('covers no options', function () { + db = new Abstract(); + }); + + describe('Interface', function () { + it('covers supported versions', function () { + const { min, max } = db.schemaVersionsSupported; + assert('major' in max && 'minor' in max && 'patch' in max); + assert('major' in min && 'minor' in min && 'patch' in min); + }); + it('covers abstract methods', async function () { + await Promise.all(interfaceMethods(Abstract) + .map(async (method) => { + await assert.rejects(async () => db[method](), NotImplemented); // eslint-disable-line security/detect-object-injection + })); + }); + }); // Interface + +}); // Abstract \ No newline at end of file diff --git a/test/src/db/base.js b/test/src/db/base.js index 9752b6a..fab28fb 100644 --- a/test/src/db/base.js +++ b/test/src/db/base.js @@ -43,12 +43,11 @@ describe('BaseDatabase', function () { 'transaction', 'getAuthById', 'upsertAuth', - 'insertLink', + 'upsertLink', 'getLinkById', 'getLinkByUrl', 'accessLink', 'expireLink', - 'updateLink', 'getAllLinks', ]; const invokedMethods = methods.map(async (m) => { diff --git a/test/src/db/factory.js b/test/src/db/factory.js new file mode 100644 index 0000000..be05289 --- /dev/null +++ b/test/src/db/factory.js @@ -0,0 +1,39 @@ +'use strict'; + +const assert = require('node:assert'); +const sinon = require('sinon'); +const DB = require('../../../src/db'); +const { Errors: { UnsupportedEngine } } = require('@squeep/db-helper'); +const DatabasePostgres = require('../../../src/db/postgresql'); +const DatabaseSQLite = require('../../../src/db/sqlite'); +const { StubLogger } = require('@squeep/test-helper'); + +describe('DatabaseFactory', function () { + let db, logger, options; + beforeEach(function () { + logger = new StubLogger(sinon); + options = { + db: { + connectionString: '', + }, + }; + }); + afterEach(function () { + sinon.restore(); + db = undefined; + }); + it('instantiates postgres db', function () { + options.db.connectionString = 'postgresql://blah', + db = new DB(logger, options); + assert(db instanceof DatabasePostgres); + }); + it('instantiates sqlite db', function () { + options.db.connectionString = 'sqlite://:memory:', + db = new DB(logger, options); + assert(db instanceof DatabaseSQLite); + }); + it('fails on unknown db', function () { + assert.throws(() => new DB(logger, options), UnsupportedEngine); + }); + +}); // DatabaseFactory% \ No newline at end of file diff --git a/test/src/db/index.js b/test/src/db/index.js deleted file mode 100644 index 16ef31f..0000000 --- a/test/src/db/index.js +++ /dev/null @@ -1,54 +0,0 @@ -'use strict'; - -const assert = require('node:assert'); -const sinon = require('sinon'); -const DatabaseFactory = require('../../../src/db'); -const DBErrors = require('../../../src/db/errors'); - -const noExpectedException = 'did not get expected exception'; - -describe('DatabaseFactory', function () { - let db, logger, options; - - beforeEach(function () { - logger = { - debug: () => {}, - error: () => {}, - info: () => {}, - }; - options = {}; - }); - - afterEach(function () { - sinon.restore(); - }); - - it('fails on invalid engine', function () { - try { - new DatabaseFactory(logger, options); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof DBErrors.UnsupportedEngine); - } - }); - - it('creates sqlite engine', function () { - this.slow(500); - options.connectionString = 'sqlite://'; - db = new DatabaseFactory(logger, options); - assert.strictEqual(db.constructor.name, 'SQLiteDatabase'); - }); - - it('creates postgres engine', function () { - this.slow(500); - const stubPgp = sinon.stub(); - stubPgp.utils = { - enumSql: sinon.stub().returns({}), - }; - stubPgp.QueryFile = sinon.stub().returns({}); - options.connectionString = 'postgresql://'; - db = new DatabaseFactory(logger, options, stubPgp); - assert.strictEqual(db.constructor.name, 'PostgresDatabase'); - }); - -}); \ No newline at end of file diff --git a/test/src/db/integration.js b/test/src/db/integration.js new file mode 100644 index 0000000..e2243e8 --- /dev/null +++ b/test/src/db/integration.js @@ -0,0 +1,192 @@ +'use strict'; + +const assert = require('node:assert'); +const { step } = require('mocha-steps'); +const sinon = require('sinon'); +const { StubLogger } = require('@squeep/test-helper'); + +describe('Database Integration', function () { + const implementations = []; + + if (!process.env.INTEGRATION_TESTS) { + it.skip('integration tests not requested'); + return; + } + + if (process.env.POSTGRES_TEST_PATH) { + implementations.push({ + name: 'PostgreSQL', + module: '../../../src/db/postgresql', + config: { + db: { + connectionString: `postgresql://${process.env.POSTGRES_TEST_PATH}`, + queryLogLevel: 'debug', + noWarnings: true, + }, + }, + }); + } + + if (process.env.SQLITE_TEST_PATH) { + implementations.push({ + name: 'SQLite', + module: '../../../src/db/sqlite', + config: { + db: { + connectionString: `sqlite://${process.env.SQLITE_TEST_PATH}`, + queryLogLevel: 'debug', + sqliteOptimizeAfterChanges: 10, + }, + }, + }); + } + + if (!implementations.length) { + it('has specified implementations to test', function () { + assert.fail('No implementations have been configured for requested intergation tests.'); + }); + return; + } + + for (const i of implementations) { + describe(i.name, function () { + let logger, DB, db; + + before(async function () { + this.timeout(10 * 1000); // Allow some time for creating tables et cetera. + DB = require(i.module); // eslint-disable-line security/detect-non-literal-require + logger = new StubLogger(sinon); + db = new DB(logger, i.config); + await db.initialize(); + await db._purgeTables(true); + }); + + after(async function () { + await db._closeConnection(); + }); + + describe('healthCheck', function () { + it('succeeds', async function () { + const result = await db.healthCheck(); + assert(result); + }); + }); // Healthcheck + + describe('Auth', function () { + let id, secret, credential; + beforeEach(function () { + id = 'username'; + secret = 'Setec Astronomy'; + credential = '$plain$hunter2'; + }); + step('no auth', async function () { + await db.context(async (dbCtx) => { + const auth = await db.getAuthById(dbCtx, id); + assert.equal(auth, undefined); // null or undefined + }); // dbCtx + }); + step('add new auth', async function () { + credential='abc123'; + await db.context(async (dbCtx) => { + await db.upsertAuth(dbCtx, id, secret, credential); + + const auth = await db.getAuthById(dbCtx, id); + assert.strictEqual(auth.id, id); + assert.strictEqual(auth.secret, secret); + assert.strictEqual(auth.password, credential); + }); + }); + step('update existing auth', async function () { + await db.context(async (dbCtx) => { + await db.upsertAuth(dbCtx, id, secret, credential); + + const auth = await db.getAuthById(dbCtx, id); + assert.strictEqual(auth.id, id); + assert.strictEqual(auth.secret, secret); + assert.strictEqual(auth.password, credential); + }); + }); + }); // Auth + + describe('Link', function () { + let id, url, authToken; + beforeEach(function () { + id = 'short'; + url = 'https://example.com/colugo'; + authToken = 'token'; + }); + step('no link by id', async function () { + await db.context(async (dbCtx) => { + const link = await db.getLinkById(dbCtx, id); + assert.equal(link, undefined); // null or undefined + }); // dbCtx + }); + step('no link by url', async function () { + await db.context(async (dbCtx) => { + const link = await db.getLinkByUrl(dbCtx, url); + assert.equal(link, undefined); // null or undefined + }); // dbCtx + }); + step('no links at all', async function () { + await db.context(async (dbCtx) => { + const links = await db.getAllLinks(dbCtx); + assert.deepStrictEqual(links, []); + }); // dbCtx + }); + step('insert link', async function () { + await db.context(async (dbCtx) => { + await db.upsertLink(dbCtx, id, url, authToken); + + const link = await db.getLinkByUrl(dbCtx, url); + assert.strictEqual(link.id, id); + assert.strictEqual(link.url, url); + assert.strictEqual(link.authToken, authToken); + assert(link.created instanceof Date); + assert.strictEqual(link.lastAccess, -Infinity); + assert.strictEqual(link.expires, null); + }); // dbCtx + }); + step('update link', async function () { + url = 'https://example.com/tayra'; + await db.context(async (dbCtx) => { + await db.upsertLink(dbCtx, id, url, authToken); + + const link = await db.getLinkByUrl(dbCtx, url); + assert.strictEqual(link.id, id); + assert.strictEqual(link.url, url); + assert.strictEqual(link.authToken, authToken); + assert(link.created instanceof Date); + assert.strictEqual(link.lastAccess, -Infinity); + assert.strictEqual(link.expires, null); + }); // dbCtx + }); + step('access link', async function () { + await db.context(async (dbCtx) => { + const accessedLink = await db.accessLink(dbCtx, id); + assert(accessedLink); + + const link = await db.getLinkById(dbCtx, id); + assert.strictEqual(link.accesses, 1); + assert(link.lastAccess instanceof Date); + }); // dbCtx + }); + step('expire link', async function () { + const when = new Date(); + await db.context(async (dbCtx) => { + await db.expireLink(dbCtx, id, when); + + const link = await db.getLinkById(dbCtx, id); + assert(link.expires instanceof Date); + }); // dbCtx + }); + step('all links', async function () { + await db.context(async (dbCtx) => { + const links = await db.getAllLinks(dbCtx); + assert.strictEqual(links.length, 1); + }); // dbCtx + }); + }); // Link + + }); // i.name + } // for i +}); // Database Integration \ No newline at end of file diff --git a/test/src/db/postgres.js b/test/src/db/postgres.js new file mode 100644 index 0000000..e7274e4 --- /dev/null +++ b/test/src/db/postgres.js @@ -0,0 +1,236 @@ +'use strict'; + +const assert = require('node:assert'); +const sinon = require('sinon'); +const { StubLogger } = require('@squeep/test-helper'); +const { + test: { + stubPgp, + interfaceMethods, + itChecksImplementation, + }, + Errors: { + UnexpectedResult, + }, +} = require('@squeep/db-helper'); +const Abstract = require('../../../src/db/abstract'); +const expectedExeption = new Error('oh no'); + +describe('DatabasePostgreSQL', function () { + const DB = require('../../../src/db/postgresql'); + let db, logger, options, dbCtx; + + beforeEach(function () { + logger = new StubLogger(sinon); + options = { + db: { + connectionString: 'postgresql://blah', + }, + }; + db = new DB(logger, options, stubPgp); + dbCtx = db.db; + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('Implementation', function () { + it('implements interface', async function () { + await itChecksImplementation(db, interfaceMethods(Abstract)); + }); + }); // Implementation + + describe('schemaVersionsSupported', function () { + it('is implemented', function () { + const result = db.schemaVersionsSupported; + assert('min' in result); + assert('max' in result); + }); + }); // schemaVersionsSupported + + describe('getAuthById', function () { + let id; + beforeEach(function () { + sinon.stub(db.db, 'oneOrNone'); + id = 'id'; + }); + it('stubbed success', async function () { + const expected = { + id: 'id', + secret: 'secret', + password: 'password', + }; + db.db.oneOrNone.resolves(expected); + const result = await db.getAuthById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + db.db.oneOrNone.rejects(expectedExeption); + await assert.rejects(async () => db.getAuthById(dbCtx, id), expectedExeption); + }); + }); // getAuthById + + describe('upsertAuth', function () { + let id, secret, credential; + beforeEach(function () { + sinon.stub(db.db, 'result'); + id = 'id'; + secret = 'secret'; + credential = 'credential'; + }); + it('stubbed success', async function () { + db.db.result.resolves({ rowCount: 1 }); + await db.upsertAuth(dbCtx, id, secret, credential); + }); + it('stubbed failure', async function () { + db.db.result.resolves({ rowCount: 0 }); + await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), UnexpectedResult); + }); + it('stubbed failure', async function () { + db.db.result.rejects(expectedExeption); + await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), expectedExeption); + }); + }); // upsertAuth + + describe('upsertLink', function () { + let id, url, authToken; + beforeEach(function () { + sinon.stub(db.db, 'result'); + id = 'id'; + url = 'https://example.com/tully'; + authToken = 'token'; + }); + it('stubbed success', async function () { + db.db.result.resolves({ rowCount: 1 }); + await db.upsertLink(dbCtx, id, url, authToken); + }); + it('stubbed failure', async function () { + db.db.result.rejects(expectedExeption); + await assert.rejects(async () => db.upsertLink(dbCtx, id, url, authToken), expectedExeption); + }); + }); // upsertLink + + describe('getLinkById', function () { + let id; + beforeEach(function () { + sinon.stub(db.db, 'oneOrNone'); + id = 'id'; + }); + it('stubbed success', async function () { + const dbResponse = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }; + db.db.oneOrNone.resolves(dbResponse); + const result = await db.getLinkById(dbCtx, id); + assert.deepStrictEqual(result, dbResponse); + }); + it('stubbed failure', async function () { + db.db.oneOrNone.rejects(expectedExeption); + await assert.rejects(async () => db.getLinkById(dbCtx, id), expectedExeption); + }); + }); // getLinkById + + describe('getLinkByUrl', function () { + let url; + beforeEach(function () { + sinon.stub(db.db, 'oneOrNone'); + url = 'https://example.com/rah'; + }); + it('stubbed success', async function () { + const dbResponse = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }; + db.db.oneOrNone.resolves(dbResponse); + const result = await db.getLinkByUrl(dbCtx, url); + assert.deepStrictEqual(result, dbResponse); + }); + it('stubbed failure', async function () { + db.db.oneOrNone.rejects(expectedExeption); + await assert.rejects(async () => db.getLinkByUrl(dbCtx, url), expectedExeption); + }); + }); // getLinkByUrl + + describe('accessLink', function () { + let id; + beforeEach(function () { + sinon.stub(db.db, 'oneOrNone'); + id = 'id'; + }); + it('stubbed success', async function () { + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }; + db.db.oneOrNone.resolves(expected); + const result = await db.accessLink(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + db.db.oneOrNone.rejects(expectedExeption); + await assert.rejects(async () => db.accessLink(dbCtx, id), expectedExeption); + }); + }); // accessLink + + describe('expireLink', function () { + let id, expires; + beforeEach(function () { + sinon.stub(db.db, 'result'); + id = 'id'; + expires = new Date(); + }); + it('stubbed success', async function () { + const returns = { + rowCount: 1, + rows: [ { id: 1 } ], + duration: 10, + }; + db.db.result.resolves(returns); + await db.expireLink(dbCtx, id, expires); + }); + it('stubbed failure', async function () { + db.db.result.rejects(expectedExeption); + await assert.rejects(async () => db.expireLink(dbCtx, id, expires), expectedExeption); + }); + it('stubbed failure', async function () { + db.db.result.resolves({ rowCount: 0 }); + await assert.rejects(async () => db.expireLink(dbCtx, id, expires), UnexpectedResult); + }); + }); // expireLink + + describe('getAllLinks', function () { + beforeEach(function () { + sinon.stub(db.db, 'manyOrNone'); + }); + it('stubbed success', async function () { + const expected = [ + { + id: 'id', + url: 'url', + created: new Date(0), + expires: null, + lastAccess: new Date(1), + }, + ]; + db.db.manyOrNone.resolves(expected); + const result = await db.getAllLinks(dbCtx); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + db.db.manyOrNone.rejects(expectedExeption); + await assert.rejects(async () => db.getAllLinks(dbCtx), expectedExeption); + }); + }); // getAllLinks + +}); \ No newline at end of file diff --git a/test/src/db/postgres/index.js b/test/src/db/postgres/index.js index 61825ad..6f6cf04 100644 --- a/test/src/db/postgres/index.js +++ b/test/src/db/postgres/index.js @@ -6,7 +6,7 @@ const sinon = require('sinon'); const noExpectedException = 'did not get expected exception'; describe('PostgresDatabase', function () { - const PostgresDatabase = require('../../../../src/db/postgres'); + const PostgresDatabase = require('../../../../src/db/postgres-old'); let pgpStub, db, logger, options, dbCtx; beforeEach(function () { @@ -153,7 +153,7 @@ describe('PostgresDatabase', function () { }); }); // _linkToNative - describe('insertLink', function () { + describe('upsertLink', function () { let id, url, authToken; it('stubbed success', async function () { const returns = { @@ -168,7 +168,7 @@ describe('PostgresDatabase', function () { duration: 0, lastInsertRowid: undefined, }; - const result = await db.insertLink(dbCtx, id, url, authToken); + const result = await db.upsertLink(dbCtx, id, url, authToken); assert.deepStrictEqual(result, expected); }); it('stubbed failure', async function () { @@ -176,13 +176,13 @@ describe('PostgresDatabase', function () { id = 'id'; db.db.result.rejects(expectedExeption); try { - await db.insertLink(dbCtx, id, url, authToken); + await db.upsertLink(dbCtx, id, url, authToken); assert.fail(noExpectedException); } catch (e) { assert.deepStrictEqual(e, expectedExeption, noExpectedException); } }); - }); // insertLink + }); // upsertLink describe('getLinkById', function () { let id; @@ -301,39 +301,6 @@ describe('PostgresDatabase', function () { }); }); // expireLink - describe('updateLink', function () { - let id, expires; - it('stubbed success', async function () { - const returns = { - rowCount: 1, - rows: [ { id: 1 } ], - duration: 10, - }; - const expected = { - changes: 1, - duration: 10, - lastInsertRowid: 1, - }; - id = 'id'; - expires = null; - db.db.result.resolves(returns); - const result = await db.updateLink(dbCtx, id, expires); - assert.deepStrictEqual(result, expected); - }); - it('stubbed failure', async function () { - const expectedExeption = new Error('blah'); - id = 'id'; - expires = null; - db.db.result.rejects(expectedExeption); - try { - await db.updateLink(dbCtx, id, expires); - assert.fail(noExpectedException); - } catch (e) { - assert.deepStrictEqual(e, expectedExeption, noExpectedException); - } - }); - }); // updateLink - describe('getAllLinks', function () { it('stubbed success', async function () { const expected = [ diff --git a/test/src/db/sqlite.js b/test/src/db/sqlite.js new file mode 100644 index 0000000..b30db39 --- /dev/null +++ b/test/src/db/sqlite.js @@ -0,0 +1,396 @@ +'use strict'; + +const assert = require('node:assert'); +const sinon = require('sinon'); +const { StubLogger } = require('@squeep/test-helper'); +const { + test: { + interfaceMethods, + itChecksImplementation, + }, + Errors: { + UnexpectedResult, + }, +} = require('@squeep/db-helper'); +const Abstract = require('../../../src/db/abstract'); + +const expectedException = new Error('oh no'); + +describe('DatabaseSQLite', function () { + const DB = require('../../../src/db/sqlite'); + let db, logger, options, dbCtx; + + beforeEach(async function () { + logger = new StubLogger(sinon); + options = { + db: {}, + }; + db = new DB(logger, options); + await db.initialize(); + dbCtx = undefined; + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('Implementation', function () { + it('implements interface', async function () { + await itChecksImplementation(db, interfaceMethods(Abstract)); + }); + }); // Implementation + + describe('schemaVersionsSupported', function () { + it('is implemented', function () { + const result = db.schemaVersionsSupported; + assert('min' in result); + assert('max' in result); + }); + }); // schemaVersionsSupported + + describe('getAuthById', function () { + let id; + beforeEach(function () { + sinon.stub(db.statement.authGetById, 'get'); + id = 'id'; + }); + + it('stubbed success', async function () { + const expected = { + id: 'id', + secret: 'secret', + password: 'password', + }; + db.statement.authGetById.get.returns(expected); + const result = await db.getAuthById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + db.statement.authGetById.get.throws(expectedException); + await assert.rejects(async () => db.getAuthById(dbCtx, id), expectedException); + }); + }); // getAuthById + + describe('upsertAuth', function () { + let id, secret, credential; + beforeEach(function () { + sinon.stub(db.statement.authInsert, 'run').returns({ changes: 1n, lastInsertRowid: 123n }); + sinon.stub(db.statement.authUpdate, 'run').returns({ changes: 1n, lastInsertRowid: 123n }); + id = 'id'; + secret = 'secert'; + credential = 'credential'; + }); + it('stubbed insert success', async function () { + await db.upsertAuth(dbCtx, id, secret, credential); + }); + it('stubbed update success', async function () { + db.statement.authInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' }); + await db.upsertAuth(dbCtx, id, secret, credential); + }); + it('covers insert error', async function () { + db.statement.authInsert.run.throws(expectedException); + await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), expectedException); + }); + it('covers unexpected insert error', async function () { + const expectedException = UnexpectedResult; + const returns = { + changes: 0n, + lastInsertRowid: undefined, + }; + db.statement.authInsert.run.returns(returns); + await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), expectedException); + }); + it('covers update error', async function () { + db.statement.authInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' }); + db.statement.authUpdate.run.throws(expectedException); + await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), expectedException); + }); + it('covers unexpected update error', async function () { + const expectedException = UnexpectedResult; + const returns = { + changes: 0n, + lastInsertRowid: undefined, + }; + db.statement.authInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' }); + db.statement.authUpdate.run.returns(returns); + await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), expectedException); + }); + }); // upsertAuth + + describe('upsertLink', function () { + let id, url, authToken; + beforeEach(function () { + sinon.stub(db.statement.linkInsert, 'run'); + sinon.stub(db.statement.linkUpdate, 'run'); + id = 'id'; + url = 'https://example.com/fluke'; + authToken = 'token'; + }); + + it('stubbed insert success', async function () { + const info = { + changes: 1n, + lastInsertRowid: 123n, + }; + db.statement.linkInsert.run.returns(info); + await db.upsertLink(dbCtx, id, url, authToken); + }); + it('stubbed update success', async function () { + const info = { + changes: 1n, + lastInsertRowid: 123n, + }; + db.statement.linkInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' }); + db.statement.linkUpdate.run.returns(info); + await db.upsertLink(dbCtx, id, url, authToken); + }); + it('stubbed insert failure', async function () { + db.statement.linkInsert.run.throws(expectedException); + await assert.rejects(async () => db.upsertLink(dbCtx, id, url, authToken), expectedException); + }); + it('stubbed unexpected insert failure', async function () { + const expectedException = UnexpectedResult; + const returns = { + changes: 0n, + lastInsertRowid: undefined, + }; + id = 'id'; + db.statement.linkInsert.run.returns(returns); + await assert.rejects(async () => db.upsertLink(dbCtx, id, url, authToken), expectedException); + }); + it('stubbed updatefailure', async function () { + db.statement.linkInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' }); + db.statement.linkUpdate.run.throws(expectedException); + await assert.rejects(async () => db.upsertLink(dbCtx, id, url, authToken), expectedException); + }); + it('stubbed unexpected update failure', async function () { + const expectedException = UnexpectedResult; + const returns = { + changes: 0n, + lastInsertRowid: undefined, + }; + id = 'id'; + db.statement.linkInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' }); + db.statement.linkUpdate.run.returns(returns); + await assert.rejects(async () => db.upsertLink(dbCtx, id, url, authToken), expectedException); + }); + }); // upsertLink + + describe('getLinkById', function () { + let id; + + beforeEach(function () { + sinon.stub(db.statement.linkGetById, 'get'); + id = 'id'; + }); + + it('stubbed success', async function () { + const returns = { + id: 'id', + isSpecial: false, + url: 'url', + created: 0, + expires: 2, + authToken: 'abc', + lastAccess: 1, + accesses: 0, + }; + const expected = { + id: 'id', + isSpecial: false, + url: 'url', + created: new Date(0), + expires: new Date(2 * 1000), + authToken: 'abc', + lastAccess: new Date(1 * 1000), + accesses: 0, + }; + id = 'id'; + db.statement.linkGetById.get.returns(returns); + const result = await db.getLinkById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + db.statement.linkGetById.get.throws(expectedException); + await assert.rejects(async () => db.getLinkById(dbCtx, id), expectedException); + }); + }); // getLinkById + + describe('getLinkByUrl', function () { + let url; + + beforeEach(function () { + sinon.stub(db.statement.linkGetByUrl, 'get'); + url = 'https://example.com/tayra'; + }); + + it('stubbed success', async function () { + const returns = { + id: 'id', + isSpecial: false, + url: 'url', + created: 10, + expires: 123, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }; + const expected = { + id: 'id', + isSpecial: false, + url: 'url', + created: new Date(10 * 1000), + expires: new Date(123 * 1000), + authToken: 'abc', + lastAccess: -Infinity, + accesses: 0, + }; + db.statement.linkGetByUrl.get.returns(returns); + const result = await db.getLinkByUrl(dbCtx, url); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + url = 'url'; + db.statement.linkGetByUrl.get.throws(expectedException); + await assert.rejects(async () => db.getLinkByUrl(dbCtx, url), expectedException); + }); + }); // getLinkByUrl + + describe('accessLink', function () { + let id; + + beforeEach(function () { + sinon.stub(db.statement.linkGetById, 'get'); + sinon.stub(db.statement.linkAccess, 'run'); + id = 'id'; + }); + + it('stubbed exists success', async function () { + const returns = { + id: 'id', + isSpecial: false, + url: 'url', + created: 0, + expires: 0, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }; + const expected = { + id: 'id', + isSpecial: false, + url: 'url', + created: new Date(0), + expires: new Date(0), + authToken: 'abc', + lastAccess: -Infinity, + accesses: 0, + }; + db.statement.linkGetById.get.returns(returns); + db.statement.linkAccess.run.returns({ changes: 1n }); + const result = await db.accessLink(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed missing success', async function () { + const returns = undefined; + const expected = undefined; + id = 'id'; + db.statement.linkGetById.get.returns(returns); + db.statement.linkAccess.run.returns({ changes: 0 }); + const result = await db.accessLink(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed increment failure', async function () { + const expectedException = UnexpectedResult; + const returns = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }; + db.statement.linkGetById.get.returns(returns); + db.statement.linkAccess.run.returns({ changes: 0 }); + await assert.rejects(async () => db.accessLink(dbCtx, id), expectedException); + }); + it('stubbed failure', async function () { + db.statement.linkGetById.get.throws(expectedException); + await assert.rejects(async () => db.accessLink(dbCtx, id), expectedException); + }); + }); // accessLink + + describe('expireLink', function () { + let id, expires; + + beforeEach(function () { + sinon.stub(db.statement.linkExpire, 'run'); + id = 'id'; + expires = new Date(); + }); + + it('stubbed success', async function () { + const returns = { + changes: 1n, + lastInsertRowid: 123n, + }; + db.statement.linkExpire.run.returns(returns); + await db.expireLink(dbCtx, id, expires); + }); + it('stubbed change failure', async function () { + const expectedException = UnexpectedResult; + const returns = { + changes: 0n, + lastInsertRowid: undefined, + }; + db.statement.linkExpire.run.returns(returns); + await assert.rejects(async () => db.expireLink(dbCtx, id, expires), expectedException); + }); + it('stubbed failure', async function () { + db.statement.linkExpire.run.throws(expectedException); + await assert.rejects(async () => db.expireLink(dbCtx, id, expires), expectedException); + }); + }); // expireLink + + describe('getAllLinks', function () { + beforeEach(function () { + sinon.stub(db.statement.linkGetAll, 'all'); + }); + + it('stubbed success', async function () { + const returns = [ + { + id: 'id', + isSpecial: false, + url: 'url', + created: 0, + expires: null, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }, + ]; + const expected = [ + { + id: 'id', + isSpecial: false, + url: 'url', + created: new Date(0), + expires: null, + authToken: 'abc', + lastAccess: -Infinity, + accesses: 0, + }, + ]; + db.statement.linkGetAll.all.returns(returns); + const result = await db.getAllLinks(dbCtx); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + db.statement.linkGetAll.all.throws(expectedException); + await assert.rejects(async () => db.getAllLinks(dbCtx), expectedException); + }); + }); // getAllLinks + +}); \ No newline at end of file diff --git a/test/src/db/sqlite/index.js b/test/src/db/sqlite/index.js index f437482..8bd7ccd 100644 --- a/test/src/db/sqlite/index.js +++ b/test/src/db/sqlite/index.js @@ -7,7 +7,7 @@ const DBErrors = require('../../../../src/db/errors'); const noExpectedException = 'did not get expected exception'; describe('SQLiteDatabase', function () { - const SQLiteDatabase = require('../../../../src/db/sqlite'); + const SQLiteDatabase = require('../../../../src/db/sqlite-old'); let db, logger, options, dbCtx; beforeEach(function () { @@ -116,10 +116,10 @@ describe('SQLiteDatabase', function () { }); }); // upsertAuth - describe('insertLink', function () { + describe('upsertLink', function () { let id, url, authToken; beforeEach(function () { - sinon.stub(db.statement.insertLink, 'run'); + sinon.stub(db.statement.upsertLink, 'run'); sinon.stub(db.statement.updateLink, 'run'); }); @@ -129,12 +129,12 @@ describe('SQLiteDatabase', function () { lastInsertRowid: BigInt(123), }; id = 'id'; - db.statement.insertLink.run.returns(info); + db.statement.upsertLink.run.returns(info); const expected = { changes: 1, lastInsertRowid: 123, }; - const result = await db.insertLink(dbCtx, id, url, authToken); + const result = await db.upsertLink(dbCtx, id, url, authToken); assert.deepStrictEqual(result, expected); }); it('stubbed update success', async function () { @@ -143,21 +143,21 @@ describe('SQLiteDatabase', function () { lastInsertRowid: BigInt(123), }; id = 'id'; - db.statement.insertLink.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' }); + db.statement.upsertLink.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' }); db.statement.updateLink.run.returns(info); const expected = { changes: 1, lastInsertRowid: 123, }; - const result = await db.insertLink(dbCtx, id, url, authToken); + const result = await db.upsertLink(dbCtx, id, url, authToken); assert.deepStrictEqual(result, expected); }); it('stubbed failure', async function () { const expectedExeption = new Error('blah'); id = 'id'; - db.statement.insertLink.run.throws(expectedExeption); + db.statement.upsertLink.run.throws(expectedExeption); try { - await db.insertLink(dbCtx, id, url, authToken); + await db.upsertLink(dbCtx, id, url, authToken); assert.fail(noExpectedException); } catch (e) { assert.deepStrictEqual(e, expectedExeption, noExpectedException); @@ -170,15 +170,15 @@ describe('SQLiteDatabase', function () { lastInsertRowid: undefined, }; id = 'id'; - db.statement.insertLink.run.returns(returns); + db.statement.upsertLink.run.returns(returns); try { - await db.insertLink(dbCtx, id, url, authToken); + await db.upsertLink(dbCtx, id, url, authToken); assert.fail(noExpectedException); } catch (e) { assert(e instanceof expectedException); } }); - }); // insertLink + }); // upsertLink describe('getLinkById', function () { let id; @@ -401,57 +401,6 @@ describe('SQLiteDatabase', function () { }); }); // expireLink - describe('updateLink', function () { - let id, expires; - - beforeEach(function () { - sinon.stub(db.statement.updateLink, 'run'); - }); - - it('stubbed success', async function () { - const returns = { - changes: 1, - lastInsertRowid: 1, - }; - const expected = { - changes: 1, - lastInsertRowid: 1, - }; - id = 'id'; - expires = null; - db.statement.updateLink.run.returns(returns); - const result = await db.updateLink(dbCtx, id, expires); - assert.deepStrictEqual(result, expected); - }); - it('stubbed change failure', async function () { - const expectedExeption = DBErrors.UnexpectedResult; - const returns = { - changes: 0, - lastInsertRowid: undefined, - }; - id = 'id'; - db.statement.updateLink.run.returns(returns); - try { - await db.updateLink(dbCtx, id); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof expectedExeption, noExpectedException); - } - }); - it('stubbed failure', async function () { - const expectedExeption = new Error('blah'); - id = 'id'; - expires = null; - db.statement.updateLink.run.throws(expectedExeption); - try { - await db.updateLink(dbCtx, id, expires); - assert.fail(noExpectedException); - } catch (e) { - assert.deepStrictEqual(e, expectedExeption, noExpectedException); - } - }); - }); // updateLink - describe('getAllLinks', function () { beforeEach(function () { sinon.stub(db.statement.linkGetAll, 'all'); diff --git a/test/src/manager.js b/test/src/manager.js index 1607a19..e22673b 100644 --- a/test/src/manager.js +++ b/test/src/manager.js @@ -24,7 +24,7 @@ describe('Manager', function () { accessLink: sinon.stub(), getLinkByUrl: sinon.stub(), expireLink: sinon.stub(), - insertLink: sinon.stub(), + upsertLink: sinon.stub(), getAllLinks: sinon.stub(), }; options = {}; @@ -142,7 +142,7 @@ describe('Manager', function () { describe('_validateContextURL', function () { it('allows admin to create local static link', function () { - ctx.sourceLink = `${manager.staticDirectory}file.txt`; + ctx.sourceLink = `${manager.staticRoutePrefix}file.txt`; ctx.authenticationId = 'awoo'; manager._validateContextURL(ctx); }); @@ -197,7 +197,7 @@ describe('Manager', function () { it('handles local static link', async function () { const file = 'foop.txt'; link = { - url: `${manager.staticDirectory}${file}`, + url: `${manager.staticRoutePrefix}${file}`, }; manager.db.accessLink.resolves(link); try { @@ -233,7 +233,7 @@ describe('Manager', function () { it('creates a link', async function () { ctx.parsedBody.url = 'http://example.com/insert'; await manager.postRoot(res, ctx); - assert(manager.db.insertLink.called); + assert(manager.db.upsertLink.called); assert(res.end.called); }); it('returns existing link', async function () { @@ -244,7 +244,7 @@ describe('Manager', function () { }; manager.db.getLinkByUrl.resolves(existingLink); await manager.postRoot(res, ctx); - assert(!manager.db.insertLink.called); + assert(!manager.db.upsertLink.called); assert(!manager.db.expireLink.called); assert(res.end.called); }); @@ -257,7 +257,7 @@ describe('Manager', function () { }; manager.db.getLinkByUrl.resolves(existingLink); await manager.postRoot(res, ctx); - assert(!manager.db.insertLink.called); + assert(!manager.db.upsertLink.called); assert(manager.db.expireLink.called); assert(res.end.called); }); @@ -284,7 +284,7 @@ describe('Manager', function () { }; manager.db.getLinkById.resolves(existingLink); await manager.putById(res, ctx); - assert(manager.db.insertLink.called); + assert(manager.db.upsertLink.called); assert(res.end.called); }); it('does not create without admin', async function () { @@ -300,7 +300,7 @@ describe('Manager', function () { ctx.parsedBody.url = url; ctx.authenticationId = 'blah'; await manager.putById(res, ctx); - assert(manager.db.insertLink.called); + assert(manager.db.upsertLink.called); assert.strictEqual(res.statusCode, 201); assert(res.end.called); }); diff --git a/test/src/service.js b/test/src/service.js index eee860f..6259a56 100644 --- a/test/src/service.js +++ b/test/src/service.js @@ -163,16 +163,6 @@ describe('service', function () { }); }); // handlerGetIdInfo - describe('handlerGetStatic', function () { - it('covers', async function () { - ctx.params = { - file: '', - }; - await service.handlerGetStatic(req, res, ctx); - assert(service.serveFile.called); - }); - }); // handlerGetStatic - describe('handlerGetAdminReport', function () { it('covers', async function () { await service.handlerGetAdminReport(req, res, ctx); diff --git a/test/stub-db.js b/test/stub-db.js new file mode 100644 index 0000000..9186699 --- /dev/null +++ b/test/stub-db.js @@ -0,0 +1,6 @@ +'use strict'; + +const { test: { stubCreator } } = require('@squeep/db-helper'); +const Abstract = require('../src/db/abstract'); + +module.exports = stubCreator(Abstract); \ No newline at end of file -- 2.49.1