use db-helper module, assorted rework/cleanup
authorJustin Wind <justin.wind+git@gmail.com>
Tue, 15 Jul 2025 21:52:47 +0000 (14:52 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Tue, 15 Jul 2025 21:52:47 +0000 (14:52 -0700)
69 files changed:
README.md
package-lock.json
package.json
server.js
src/authenticator.js
src/db/abstract.js [new file with mode: 0644]
src/db/base.js
src/db/index-old.js [new file with mode: 0644]
src/db/index.js
src/db/postgres-old/index.js [moved from src/db/postgres/index.js with 95% similarity]
src/db/postgres-old/sql/auth-get-by-id.sql [moved from src/db/postgres/sql/auth-get-by-id.sql with 100% similarity]
src/db/postgres-old/sql/auth-upsert.sql [moved from src/db/postgres/sql/auth-upsert.sql with 100% similarity]
src/db/postgres-old/sql/link-access.sql [moved from src/db/postgres/sql/link-access.sql with 100% similarity]
src/db/postgres-old/sql/link-expire.sql [moved from src/db/postgres/sql/link-expire.sql with 100% similarity]
src/db/postgres-old/sql/link-get-all.sql [moved from src/db/postgres/sql/link-get-all.sql with 100% similarity]
src/db/postgres-old/sql/link-get-by-id.sql [moved from src/db/postgres/sql/link-get-by-id.sql with 100% similarity]
src/db/postgres-old/sql/link-get-by-url.sql [moved from src/db/postgres/sql/link-get-by-url.sql with 100% similarity]
src/db/postgres-old/sql/link-update.sql [moved from src/db/postgres/sql/link-update.sql with 100% similarity]
src/db/postgres-old/sql/link-upsert.sql [moved from src/db/postgres/sql/link-upsert.sql with 100% similarity]
src/db/postgres-old/sql/schema/1.0.0/apply.sql [moved from src/db/postgres/sql/schema/1.0.0/apply.sql with 87% similarity]
src/db/postgres-old/sql/schema/1.0.0/revert.sql [moved from src/db/postgres/sql/schema/1.0.0/revert.sql with 100% similarity]
src/db/postgres-old/sql/schema/1.0.1/apply.sql [moved from src/db/postgres/sql/schema/1.0.1/apply.sql with 100% similarity]
src/db/postgres-old/sql/schema/1.0.1/revert.sql [moved from src/db/postgres/sql/schema/1.0.1/revert.sql with 100% similarity]
src/db/postgres-old/sql/schema/init.sql [moved from src/db/postgres/sql/schema/init.sql with 100% similarity]
src/db/postgresql/index.js [new file with mode: 0644]
src/db/postgresql/sql/auth-get-by-id.sql [new file with mode: 0644]
src/db/postgresql/sql/auth-upsert.sql [new file with mode: 0644]
src/db/postgresql/sql/link-access.sql [new file with mode: 0644]
src/db/postgresql/sql/link-expire.sql [new file with mode: 0644]
src/db/postgresql/sql/link-get-all.sql [new file with mode: 0644]
src/db/postgresql/sql/link-get-by-id.sql [new file with mode: 0644]
src/db/postgresql/sql/link-get-by-url.sql [new file with mode: 0644]
src/db/postgresql/sql/link-upsert.sql [new file with mode: 0644]
src/db/postgresql/sql/schema/1.0.0/apply.sql [new file with mode: 0644]
src/db/postgresql/sql/schema/1.0.0/revert.sql [new file with mode: 0644]
src/db/postgresql/sql/schema/1.0.1/apply.sql [new file with mode: 0644]
src/db/postgresql/sql/schema/1.0.1/revert.sql [new file with mode: 0644]
src/db/sqlite-old/index.js [new file with mode: 0644]
src/db/sqlite/index.js
src/db/sqlite/sql/auth-get-by-id.sql [new file with mode: 0644]
src/db/sqlite/sql/auth-insert.sql [new file with mode: 0644]
src/db/sqlite/sql/auth-update.sql [new file with mode: 0644]
src/db/sqlite/sql/link-access.sql [new file with mode: 0644]
src/db/sqlite/sql/link-expire.sql [new file with mode: 0644]
src/db/sqlite/sql/link-get-all.sql [new file with mode: 0644]
src/db/sqlite/sql/link-get-by-id.sql [new file with mode: 0644]
src/db/sqlite/sql/link-get-by-url.sql [new file with mode: 0644]
src/db/sqlite/sql/link-insert.sql [new file with mode: 0644]
src/db/sqlite/sql/link-update.sql [new file with mode: 0644]
src/db/sqlite/sql/schema/1.0.0/apply.sql [new file with mode: 0644]
src/db/sqlite/sql/schema/1.0.0/revert.sql [new file with mode: 0644]
src/db/sqlite/sql/schema/1.0.1/apply.sql [new file with mode: 0644]
src/db/sqlite/sql/schema/1.0.1/revert.sql [new file with mode: 0644]
src/manager.js
src/service.js
src/template/info-html.js
src/template/report-html.js
test/src/db/abstract.js [new file with mode: 0644]
test/src/db/base.js
test/src/db/factory.js [new file with mode: 0644]
test/src/db/index.js [deleted file]
test/src/db/integration.js [new file with mode: 0644]
test/src/db/postgres.js [new file with mode: 0644]
test/src/db/postgres/index.js
test/src/db/sqlite.js [new file with mode: 0644]
test/src/db/sqlite/index.js
test/src/manager.js
test/src/service.js
test/stub-db.js [new file with mode: 0644]

index 3681c58b99e1c44091515fcf501ca174ba16ccda..b39bf11721bbf6877574ef78b04be723710c4bdd 100644 (file)
--- 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.
index c59083473690170ce6f4a5c21adf6781c7971cd3..b80880d7871d9efef867faa995201ce8d2f1cb2a 100644 (file)
@@ -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",
       },
       "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"
       },
         "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",
       }
     },
     "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",
       }
     },
     "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",
         "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",
         "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",
index 918cf344f462e5ba4942399a9c1d68d3867b3163..aadedcc7428466f92aeca679678e9d76e494ec25 100644 (file)
   "author": "Justin Wind <jwind-urlittler@squeep.com>",
   "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",
   },
   "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"
   }
index 1dece0354e342ab8e8ee8125b58763f444328af5..1770dd9adb599c68561c3dddd884f9ce0208d919 100644 (file)
--- 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);
index eeb6f201244e72f1f1bade2416031f1909608700..274f197a0c5530ea4b64cb03ab893019f27bfd7c 100644 (file)
@@ -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<void>}
+   * @returns {Promise<boolean>} 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 (file)
index 0000000..9625fda
--- /dev/null
@@ -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|undefined>} 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<void>}
+   */
+  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<void>}
+   */
+  async upsertLink(dbCtx, id, url, authToken) {
+    this._notImplemented('upsertLink', arguments);
+  }
+
+  /**
+   * 
+   * @param {*} dbCtx db context
+   * @param {string} id id
+   * @returns {Promise<Link|undefined>} link
+   */
+  async getLinkById(dbCtx, id) {
+    this._notImplemented('getLinkById', arguments);
+  }
+
+  /**
+   * 
+   * @param {*} dbCtx db context
+   * @param {string} url url
+   * @returns {Promise<Link|undefined>} link
+   */
+  async getLinkByUrl(dbCtx, url) {
+    this._notImplemented('getLinkByUrl', arguments);
+  }
+
+  /**
+   * 
+   * @param {*} dbCtx db context
+   * @param {string} id id
+   * @returns {Promise<Link|undefined>} link
+   */
+  async accessLink(dbCtx, id) {
+    this._notImplemented('accessLink', arguments);
+  }
+
+  /**
+   * 
+   * @param {*} dbCtx db context
+   * @param {string} id id
+   * @param {Date} expires expire date
+   * @returns {Promise<void>}
+   */
+  async expireLink(dbCtx, id, expires) {
+    this._notImplemented('expireLink', arguments);
+  }
+
+  /**
+   * 
+   * @param {*} dbCtx db context
+   * @returns {Promise<Link[]>} links
+   */
+  async getAllLinks(dbCtx) {
+    this._notImplemented('getAllLinks', arguments);
+  }
+
+}
+
+module.exports = Database;
\ No newline at end of file
index 6bce83b800eadcaf1ea25e1b6b156c401c8dddb4..db20422161f299f18791e507aed62677b9d8b6b9 100644 (file)
@@ -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 (file)
index 0000000..b7c9549
--- /dev/null
@@ -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;
index acb8cba574e57638aae73ca13c417f07d45c8d66..730808eaa1327af302767266b5887e40ca495926 100644 (file)
@@ -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
similarity index 95%
rename from src/db/postgres/index.js
rename to src/db/postgres-old/index.js
index 7549133a72604f24c58464c542836cbc65d4bdbd..775a0a6b16cd5a1b3226aa497c019104fa1a01af 100644 (file)
@@ -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', { });
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 ddfaaffbff9b305b682ac3925cbe64dc947bac36..c05abc93a76d5c3dd7364d2d47cc18570f5d9f13 100644 (file)
@@ -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/postgresql/index.js b/src/db/postgresql/index.js
new file mode 100644 (file)
index 0000000..adbdb4f
--- /dev/null
@@ -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 (file)
index 0000000..98f0434
--- /dev/null
@@ -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 (file)
index 0000000..f90be73
--- /dev/null
@@ -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 (file)
index 0000000..0c63042
--- /dev/null
@@ -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 (file)
index 0000000..66aa809
--- /dev/null
@@ -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 (file)
index 0000000..0036268
--- /dev/null
@@ -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 (file)
index 0000000..3f656ec
--- /dev/null
@@ -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 (file)
index 0000000..dae277d
--- /dev/null
@@ -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 (file)
index 0000000..8767233
--- /dev/null
@@ -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 (file)
index 0000000..cd46318
--- /dev/null
@@ -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 (file)
index 0000000..38ed34d
--- /dev/null
@@ -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 (file)
index 0000000..5be80f9
--- /dev/null
@@ -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 (file)
index 0000000..08fa1c4
--- /dev/null
@@ -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 (file)
index 0000000..610a3d3
--- /dev/null
@@ -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;
index e1ce14dc58ddbb3536ef7bf68c015ceba7fb7c1d..aed3110409574c57fcd2770ea38e20151c1b8c37 100644 (file)
 '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 (file)
index 0000000..a579d16
--- /dev/null
@@ -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 (file)
index 0000000..d315036
--- /dev/null
@@ -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 (file)
index 0000000..d44fbfb
--- /dev/null
@@ -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 (file)
index 0000000..e2f0ab4
--- /dev/null
@@ -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 (file)
index 0000000..eef3dd7
--- /dev/null
@@ -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 (file)
index 0000000..1594a5d
--- /dev/null
@@ -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 (file)
index 0000000..7940dea
--- /dev/null
@@ -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 (file)
index 0000000..301a3cc
--- /dev/null
@@ -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 (file)
index 0000000..77a5808
--- /dev/null
@@ -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 (file)
index 0000000..0d44e6a
--- /dev/null
@@ -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 (file)
index 0000000..43d09bf
--- /dev/null
@@ -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 (file)
index 0000000..a29edc5
--- /dev/null
@@ -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 (file)
index 0000000..488692b
--- /dev/null
@@ -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 (file)
index 0000000..e7b3c22
--- /dev/null
@@ -0,0 +1,6 @@
+BEGIN;
+
+ALTER TABLE link
+       DROP COLUMN is_special
+
+COMMIT;
index c510f18d3daf531547915d756e63964cf1877cda..8807f53eb7f3624c4177cc5c57e9ecfb3ea61bc2 100644 (file)
@@ -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
       });
     });
index 18d5736748a14ce21871171f44f09dfe2f30115e..6e26700c919005d7f97bb28945f021bb7761c345 100644 (file)
@@ -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<void>}
-   */
-  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
index d18a2c51b9d7460e193625a00c6ce4a606731538..399fbb94211019571af1f43ea146267feb0d7384 100644 (file)
@@ -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 `<section>
 \t<div class="identifier">id: <span><a href="/${details.id}">${details.id}</a></span></div>
index 9c995d92f7c200b0681e71625167764a0efb977d..7734d69b5b740a1e0d206cbce957b19d460a1dfd 100644 (file)
@@ -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<th scope="row"><a href="/${link.id}/info">${link.id}</a></th>
 \t<td>${link.url}</td>
 \t<td>${link.accesses}</td>
-\t<td>${toDateString(link.created)}</td>
-\t<td>${toDateString(link.expires)}</td>
-\t<td>${toDateString(link.lastAccess)}</td>
+\t<td>${th.dateFormat(link.created)}</td>
+\t<td>${th.dateFormat(link.expires, 'never', 'forever', 'never')}</td>
+\t<td>${th.dateFormat(link.lastAccess, 'never', 'never', 'never')}</td>
 </tr>`;
 }
 
diff --git a/test/src/db/abstract.js b/test/src/db/abstract.js
new file mode 100644 (file)
index 0000000..e0da09c
--- /dev/null
@@ -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
index 9752b6afa55e8cf6610de2db951ab154fc8d73b8..fab28fb470738b62511a560eab614a05b60a6c20 100644 (file)
@@ -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 (file)
index 0000000..be05289
--- /dev/null
@@ -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 (file)
index 16ef31f..0000000
+++ /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 (file)
index 0000000..e2243e8
--- /dev/null
@@ -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 (file)
index 0000000..e7274e4
--- /dev/null
@@ -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
index 61825ad2261546a5869a6abed2e9631f39d2a14b..6f6cf04a037f7e555baeb7b1031aa07e96c10190 100644 (file)
@@ -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 (file)
index 0000000..b30db39
--- /dev/null
@@ -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
index f437482c31cf37bb80e606070cab8ecbf2d8e986..8bd7ccdf04f122ea8cf818d49124697b019c18e8 100644 (file)
@@ -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');
index 1607a19168d419e258c87cd833b26bde50785edf..e22673ba5654f36aa1b1b02bdc593ba0636dd4f4 100644 (file)
@@ -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);
     });
index eee860f461453694f94c8be5e15da2529de20f70..6259a56b61797e9c203e36c446637dbe62542490 100644 (file)
@@ -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 (file)
index 0000000..9186699
--- /dev/null
@@ -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