See files in `test-integration` for example implementation.
-Your database interface is defined by extending the Abstract class.
+The Abstract class provides boilerplate initialization, and defines some abstract methods which the engines implement.
+Your database interface is defined by extending this Abstract class with your abstract interface methods..
-Your database engines are implemented by extending a class created by passing your abstract interface class to an engine class creator function.
+Your abstract interface class is then provided to a function for an engine, which creates an abstract engine class that includes engine-specific boilerplate and implements some of the base abstract methods.
+Your engine implementations extend this abstract engine class.
const PostgresCreator = require('./lib/postgres-creator');
const SQLiteCreator = require('./lib/sqlite-creator');
const { validate } = require('./lib/validation');
+const { stubCreator, stubPgp } = require('./test/stub');
+const { itChecksImplementation } = require('./test/helpers');
module.exports = {
Abstract,
SQLiteCreator,
PostgresCreator,
validate,
+ test: {
+ itChecksImplementation,
+ stubCreator,
+ stubPgp,
+ },
};
if (!this._isProduction) {
// Configure metadata used by tests and extra validation when not in production.
- // List of generic methods an engine needs to implement to reify this model.
- this._abstractMethods = [
- 'schemaVersionsSupported',
- '_currentSchema',
- '_tableExists',
- 'healthCheck',
- 'context',
- 'transaction',
- ];
-
- // List of methods specific to database interface.
- this._interfaceMethods = [];
-
// List of tables for tests to purge during integration tests.
this._tableNames = [];
}
constructor(...args) {
super(...args);
if (!this._isProduction) {
- this._abstractMethods.push(...[
- 'almanacGetAll',
- 'almanacGet',
- 'almanacUpsert',
- ]);
this._tableNames.push(...[
'almanac',
]);
stubPgp.end = nop;
-async function itChecksImplementation(db) {
- const results = await Promise.allSettled(db._abstractMethods.map(async (fn) => {
+/**
+ * Mocha function to validate all interface methods are implemented.
+ * @param {*} db
+ * @param {String[]} interfaceMethods
+ */
+async function itChecksImplementation(db, interfaceMethods) {
+ const results = await Promise.allSettled(interfaceMethods.map(async (fn) => {
try {
await db[fn](db.db); // eslint-disable-line security/detect-object-injection
} catch (e) {
const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
const { StubLogger } = require('../helpers');
const Abstract = require('../../lib/abstract');
-const { DataValidation, MigrationNeeded, NotImplemented } = require('../../lib/errors');
+const { MigrationNeeded, NotImplemented } = require('../../lib/errors');
describe('Abstract', function () {
let db, stubLogger, options;
})
}); // _notImplemented
- describe('schemaVersionsSupported', function () {
- it('needs implementation', function () {
- assert.throws(() => db.schemaVersionsSupported, NotImplemented);
+ describe('abstract methods', function () {
+ const abstractMethods = [
+ '_currentSchema',
+ '_tableExists',
+ 'healthCheck',
+ 'context',
+ 'transaction',
+ ];
+ it('need implementation', async function () {
+ for await (const m of abstractMethods) {
+ await assert.rejects(() => db[m](), NotImplemented);
+ }
});
- }); // schemaVersionsSupported
+ }); // abstract methods
- describe('_tableExists', function () {
- it('needs implementation', async function () {
- assert.rejects(() => db._tableExists(), NotImplemented);
- });
- }); // _currentSchema
-
- describe('_currentSchema', function () {
- it('needs implementation', async function () {
- assert.rejects(() => db._currentSchema(), NotImplemented);
- });
- }); // _currentSchema
-
- describe('healthCheck', function () {
- it('needs implementation', async function () {
- assert.rejects(() => db.healthCheck(), NotImplemented);
- });
- }); // healthCheck
-
- describe('context', function () {
- it('needs implementation', async function () {
- assert.rejects(() => db.context(), NotImplemented);
- });
- }); // context
+ describe('abstract getters', function () {
+ const abstractGetters = [
+ 'schemaVersionsSupported',
+ ];
- describe('transaction', function () {
- it('needs implementation', async function () {
- assert.rejects(() => db.transaction(), NotImplemented);
+ it('need implementation', function () {
+ for (const m of abstractGetters) {
+ assert.throws(() => db[m], NotImplemented);
+ }
});
- }); // transaction
+ }); // abstract getters
}); // Abstract
const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
const fs = require('node:fs');
const Abstract = require('../../lib/abstract');
+const { interfaceMethods, stubPgp } = require('../stub');
const PostgresCreator = require('../../lib/postgres-creator');
const DBErrors = require('../../lib/errors');
-const { StubLogger, stubPgp, nop, itChecksImplementation } = require('../helpers');
+const { StubLogger, nop, itChecksImplementation } = require('../helpers');
describe('Postgres Creator', function () {
let db, options, stubLogger;
assert(db);
});
+ /**
+ * No interface methods here, but provided as example for derivative tests.
+ */
describe('Implementation', function () {
- it('implements engine interface', () => itChecksImplementation(db));
+ it('implements engine interface', function () {
+ const methods = interfaceMethods(Abstract);
+ itChecksImplementation(db, methods);
+ });
}); // Implementation
describe('_pgpInitOptions', function () {
const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
const fs = require('node:fs');
const Abstract = require('../../lib/abstract');
+const { interfaceMethods } = require('../stub');
const SQLiteCreator = require('../../lib/sqlite-creator');
const DBErrors = require('../../lib/errors');
-const { StubLogger, stubPgp, nop, itChecksImplementation } = require('../helpers');
+const { StubLogger, nop, itChecksImplementation } = require('../helpers');
describe('DatabaseSQLite', function () {
let db, options, stubLogger;
};
stubLogger = new StubLogger();
stubLogger._spy();
- db = new DatabaseSQLite(stubLogger, options, stubPgp);
+ db = new DatabaseSQLite(stubLogger, options);
sinon.stub(fs, 'readdirSync').returns([]);
sinon.stub(fs, 'statSync').returns({
isDirectory: () => true,
it('covers default connection string', function () {
delete options.db.connectionString;
- db = new DatabaseSQLite(stubLogger, options, stubPgp);
+ db = new DatabaseSQLite(stubLogger, options);
});
it('covers no query logging', function () {
delete options.db.queryLogLevel;
- db = new DatabaseSQLite(stubLogger, options, stubPgp);
+ db = new DatabaseSQLite(stubLogger, options);
assert(db);
});
it('covers initialize without migration', function () {
- db = new DatabaseSQLite(stubLogger, options, stubPgp);
+ db = new DatabaseSQLite(stubLogger, options);
db.initialize(undefined, false);
});
+ /**
+ * No interface methods here, but provided as example for derivative tests.
+ */
describe('Implementation', function () {
- it('implements engine interface', () => itChecksImplementation(db));
+ it('implements engine interface', function () {
+ const methods = interfaceMethods(Abstract);
+ itChecksImplementation(db, methods);
+ });
}); // Implementation
describe('_initTables', function () {
'use strict';
const assert = require('node:assert');
-const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
-const { StubLogger } = require('../helpers');
const Validation = require('../../lib/validation');
const { DataValidation } = require('../../lib/errors');
--- /dev/null
+'use strict';
+
+const sinon = require('sinon');
+
+const nop = () => undefined;
+
+class QueryFileStub {
+ error = undefined;
+ constructor(file, options) {
+ this.file = file;
+ this.options = options;
+ }
+}
+
+/**
+ * Creates an object to supply to derived postgres implementations for testing.
+ * @returns {Object}
+ */
+const stubPgp = () => {
+ const stub = {
+ result: () => ({ rows: [] }),
+ all: nop,
+ get: nop,
+ run: nop,
+ one: nop,
+ manyOrNone: nop,
+ oneOrNone: nop,
+ query: nop,
+ batch: nop,
+ multiResult: nop,
+ connect: nop,
+ };
+ stub.tx = (fn) => fn(stub);
+ stub.txIf = (fn) => fn(stub);
+ stub.task = (fn) => fn(stub);
+ return stub;
+};
+stubPgp.pg = {
+ types: {
+ getTypeParser: nop,
+ setTypeParser: nop,
+ },
+};
+stubPgp.QueryFile = QueryFileStub;
+stubPgp.utils = {
+ enumSql: () => ({}),
+};
+stubPgp.end = nop;
+
+
+/**
+ * Default lists of extant methods on abstract class.
+ */
+const stubMetadata = () => {
+ return {
+ // List of generic getters an engine needs to implement.
+ // These throw NotImplemented from Abstract.
+ abstractGetters: [
+ 'schemaVersionsSupported',
+ ],
+
+ // List of generic methods an engine needs to implement to reify this model.
+ // These throw NotImplemented from Abstract.
+ abstractMethods: [
+ '_currentSchema',
+ '_tableExists',
+ 'healthCheck',
+ 'context',
+ 'transaction',
+ ],
+
+ // These methods get spies instead of stubs.
+ spyMethods: [
+ 'context',
+ 'transaction',
+ ],
+
+ // Populated on stub class.
+ // These should all throw NotImplemented from Abstract.
+ interfaceMethods: [],
+ };
+};
+
+
+/**
+ * Create a class with hooks to stub the interface methods of the provided abstract class.
+ * @param {*} Abstract
+ * @returns {*}
+ */
+function stubCreator(Abstract, metadata = stubMetadata()) {
+ // Juggle some stuff to stub an interface class.
+ class DatabaseStub extends Abstract {
+ _stubMetadata = metadata;
+
+ constructor(...args) {
+ super(...args);
+ this._stubMetadata.interfaceMethods = interfaceMethods(Abstract, metadata);
+ this._spyMethods.forEach((m) => sinon.spy(this, m));
+ this._sinonReset();
+ }
+
+ async context(fn) {
+ await fn({});
+ }
+
+ async transaction(dbCtx, fn) {
+ await fn(dbCtx);
+ }
+
+ _sinonReset() {
+ this._stubMetadata.abstractGetters
+ .forEach((m) => sinon.stub(this, m).get())
+ ;
+ this._stubMetadata.abstractMethods
+ .filter((m) => !this._stubMetadata.spyMethods.includes(m))
+ .forEach((m) => sinon.stub(this, m))
+ ;
+ this._stubMetadata.interfaceMethods
+ .forEach((m) => sinon.stub(this, m))
+ ;
+ this._stubMetadata.spyMethods
+ .forEach((m) => this[m].resetHistory())
+ ;
+ }
+ }
+ return DatabaseStub;
+}
+
+
+/**
+ * Isolate list of interface methods from abstract class.
+ * @param {*} AbstractClass
+ * @param {*} metadata
+ * @returns {String[]}
+ */
+function interfaceMethods(AbstractClass, metadata = stubMetadata()) {
+ const proto = AbstractClass.prototype;
+ return Object.getOwnPropertyNames(proto)
+ .filter((m) => typeof Object.getOwnPropertyDescriptor(proto, m).value === 'function'
+ && m !== 'constructor'
+ && !metadata.abstractGetters.includes(m)
+ && !metadata.abstractMethods.includes(m)
+ );
+}
+
+
+module.exports = {
+ interfaceMethods,
+ stubMetadata,
+ stubCreator,
+ stubPgp,
+};