Initial release
[websub-hub] / test / src / db / postgres.js
diff --git a/test/src/db/postgres.js b/test/src/db/postgres.js
new file mode 100644 (file)
index 0000000..5df49fd
--- /dev/null
@@ -0,0 +1,1502 @@
+/* eslint-disable sonarjs/no-identical-functions */
+/* eslint-env mocha */
+/* eslint-disable sonarjs/no-duplicate-string */
+'use strict';
+
+/* This provides implementation coverage, stubbing pg-promise. */
+
+const assert = require('assert');
+// eslint-disable-next-line node/no-unpublished-require
+const sinon = require('sinon');
+const DBStub = require('../../stub-db');
+const stubLogger = require('../../stub-logger');
+const DB = require('../../../src/db/postgres');
+const DBErrors = require('../../../src/db/errors');
+const common = require('../../../src/common');
+const Config = require('../../../config');
+
+const noExpectedException = 'did not receive expected exception';
+
+describe('DatabasePostgres', function () {
+  let db, options, pgpStub;
+  let dbCtx, claimant, claimTimeoutSeconds, callback, subscriptionId, topicId, verificationId;
+  let topicUrl, leaseSeconds, secret, httpRemoteAddr, httpFrom, retryDelays, wanted;
+  before(function () {
+    pgpStub = () => {
+      const stub = {
+        result: () => ({ rows: [] }),
+        all: common.nop,
+        get: common.nop,
+        run: common.nop,
+        one: common.nop,
+        manyOrNone: common.nop,
+        oneOrNone: common.nop,
+        query: common.nop,
+        batch: common.nop,
+        multiResult: common.nop,
+        connect: common.nop,
+      };
+      stub.tx = (fn) => fn(stub);
+      stub.txIf = (fn) => fn(stub);
+      stub.task = (fn) => fn(stub);
+      return stub;
+    };
+    pgpStub.utils = {
+      enumSql: () => ({}),
+    };
+    pgpStub.QueryFile = class {};
+    pgpStub.end = common.nop,
+    options = new Config('test');
+    db = new DB(stubLogger, options, pgpStub);
+  });
+  beforeEach(function () {
+    stubLogger._reset();
+    dbCtx = db.db;
+    claimant = '19af19b8-6be3-4a6f-8946-65f5f1ccc5d7';
+    claimTimeoutSeconds = 300;
+    subscriptionId = 'fbaf8f19-ed9c-4a21-89ae-98b7005e3bf6';
+    topicUrl = 'https://example.com/blog';
+    callback = 'https://example.com/callback?id=123';
+    topicId = 'c59d4bda-10ad-41d9-99df-4ce8bc331424';
+    verificationId = '55cd7748-d2d5-11eb-b355-0025905f714a';
+    retryDelays = [60];
+    leaseSeconds = 86400;
+    secret = 'secret';
+    httpRemoteAddr = '127.0.0.1';
+    httpFrom = 'user@example.com';
+    wanted = 5;
+});
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  // Ensure all interface methods are implemented
+  describe('Implementation', function () {
+    it('implements interface', async function () {
+      const results = await Promise.allSettled(DBStub._implementation.map(async (fn) => {
+        try {
+          // eslint-disable-next-line security/detect-object-injection
+          await db[fn](db.db);
+        } catch (e) {
+          assert(!(e instanceof DBErrors.NotImplemented), `${fn} not implemented`);
+        }
+      }));
+      const failures = results.filter((x) => x.status === 'rejected');
+      assert(!failures.length, failures.map((x) => {
+        x = x.reason.toString();
+        return x.slice(x.indexOf(': '));
+      }));
+    });
+  }); // Implementation
+
+  describe('pgpInitOptions', function () {
+    describe('error', function () {
+      it('covers', function () {
+        const err = {};
+        const event = {};
+        db.pgpInitOptions.error(err, event);
+        assert(db.logger.error.called);
+      });
+    }); // error
+    describe('query', function () {
+      it('covers', function () {
+        const event = {};
+        db.pgpInitOptions.query(event);
+        assert(db.logger.debug.called);
+      });
+    }); // query
+    describe('receive', function () {
+      it('covers', function () {
+        const data = [
+          {
+            column_one: 'one', // eslint-disable-line camelcase
+            column_two: 2, // eslint-disable-line camelcase
+          },
+          {
+            column_one: 'foo', // eslint-disable-line camelcase
+            column_two: 4, // eslint-disable-line camelcase
+          },
+        ];
+        const result = {};
+        const event = {};
+        const expectedData = [
+          {
+            columnOne: 'one',
+            columnTwo: 2,
+          },
+          {
+            columnOne: 'foo',
+            columnTwo: 4,
+          },
+        ];
+        db.pgpInitOptions.receive(data, result, event)
+        assert(db.logger.debug.called);
+        assert.deepStrictEqual(data, expectedData);
+      });
+    }); // receive
+  }); // pgpInitOptions
+
+  describe('_initTables', function () {
+    beforeEach(function () {
+      sinon.stub(db.db, 'oneOrNone');
+      sinon.stub(db.db, 'multiResult');
+      sinon.stub(db, '_currentSchema');
+    });
+
+    it('covers apply', async function() {
+      db.db.oneOrNone.onCall(0).resolves(null).onCall(1).resolves({});
+      db._currentSchema.resolves({ major: 0, minor: 0, patch: 0 });
+      await db._initTables();
+    });
+    it('covers exists', async function() {
+      db.db.oneOrNone.resolves({});
+      db._currentSchema.resolves(db.schemaVersionsSupported.max);
+      await db._initTables();
+    });
+  }); // _initTables
+
+  describe('schemaCheck', function () {
+    it('passes supported version', async function () {
+      const version = { major: 1, minor: 0, patch: 0 };
+      sinon.stub(db.db, 'one').resolves(version);
+      await db.schemaCheck(false);
+    });
+    it('fails low version', async function () {
+      const version = { major: 0, minor: 0, patch: 0 };
+      sinon.stub(db.db, 'one').resolves(version);
+      try {
+        await db.schemaCheck(false);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.MigrationNeeded);
+      }
+    });
+    it('fails high version', async function () {
+      const version = { major: 100, minor: 100, patch: 100 };
+      sinon.stub(db.db, 'one').resolves(version);
+      try {
+        await db.schemaCheck(false);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.MigrationNeeded);
+      }
+    });
+    it('covers migration', async function() {
+      sinon.stub(db.db, 'oneOrNone').resolves({});
+      sinon.stub(db.db, 'multiResult');
+      sinon.stub(db, '_currentSchema').resolves(db.schemaVersionsSupported.max);
+      sinon.stub(db.db, 'one').resolves(db.schemaVersionsSupported.max);
+      await db.schemaCheck();
+    });
+  }); // schemaCheck
+
+  describe('healthCheck', function () {
+    beforeEach(function () {
+      sinon.stub(db.db, 'connect').resolves({
+        done: () => {},
+        client: {
+          serverVersion: '0.0',
+        },
+      });
+    });
+    it('covers', async function () {
+      const result = await db.healthCheck();
+      assert.deepStrictEqual(result, { serverVersion: '0.0' });
+    });
+  }); // healthCheck
+
+  describe('_queryFileHelper', function () {
+    it('covers success', function () {
+      const _queryFile = db._queryFileHelper(pgpStub);
+      _queryFile();
+    });
+    it('covers failure', function () {
+      const err = new Error();
+      pgpStub.QueryFile = class {
+        constructor() {
+          this.error = err;
+        }
+      };
+      const _queryFile = db._queryFileHelper(pgpStub);
+      try {
+        _queryFile();
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.strictEqual(e, err);
+      }
+    });
+  }); // _queryFileHelper
+
+  describe('_closeConnection', function () {
+    it('success', async function () {
+      sinon.stub(db._pgp, 'end');
+      await db._closeConnection();
+      assert(db._pgp.end.called);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db._pgp, 'end').throws(expected);
+      try {
+        await db._closeConnection();
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // _closeConnection
+
+  describe('_purgeTables', function () {
+    it('covers not really', async function () {
+      sinon.stub(db.db, 'tx');
+      await db._purgeTables(false);
+      assert(!db.db.tx.called);
+    });
+    it('success', async function () {
+      sinon.stub(db.db, 'batch');
+      await db._purgeTables(true);
+      assert(db.db.batch.called);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'tx').rejects(expected);
+      try {
+        await db._purgeTables(true);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // _purgeTables
+
+  describe('context', function () {
+    it('covers', async function () {
+      await db.context(common.nop);
+    });
+  }); // context
+
+  describe('transaction', function () {
+    it('covers', async function () {
+      await db.transaction(db.db, common.nop);
+    });
+  }); // transaction
+
+  describe('authenticationSuccess', function () {
+    let identifier;
+    beforeEach(function () {
+      identifier = 'username';
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.authenticationSuccess(dbCtx, identifier);
+    });
+    it('failure', async function() {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.authenticationSuccess(dbCtx, identifier);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // authenticationSuccess
+
+  describe('authenticationGet', function () {
+    let identifier, credential;
+    beforeEach(function () {
+      identifier = 'username';
+      credential = '$z$foo';
+    });
+    it('success', async function () {
+      const dbResult = { identifier, credential };
+      sinon.stub(db.db, 'oneOrNone').resolves(dbResult);
+      const result = await db.authenticationGet(dbCtx, identifier);
+      assert.deepStrictEqual(result, dbResult);
+    });
+    it('failure', async function() {
+      const expected = new Error('blah');
+      sinon.stub(db.db, 'oneOrNone').rejects(expected);
+      try {
+        await db.authenticationGet(dbCtx, identifier, credential);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // authenticationGet
+
+  describe('authenticationUpsert', function () {
+    let identifier, credential;
+    beforeEach(function () {
+      identifier = 'username';
+      credential = '$z$foo';
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.authenticationUpsert(dbCtx, identifier, credential);
+    });
+    it('failure', async function() {
+      credential = undefined;
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.authenticationUpsert(dbCtx, identifier, credential);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // authenticationUpsert
+
+  describe('subscriptionsByTopicId', function () {
+    it('success', async function () {
+      const expected = [];
+      sinon.stub(db.db, 'manyOrNone').resolves(expected);
+      const result = await db.subscriptionsByTopicId(dbCtx, topicUrl);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'manyOrNone').throws(expected);
+      try {
+        await db.subscriptionsByTopicId(dbCtx, topicUrl);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionsByTopicId
+
+  describe('subscriptionCountByTopicUrl', function () {
+    it('success', async function () {
+      const expected = { count: 3 };
+      sinon.stub(db.db, 'one').resolves(expected);
+      const result = await db.subscriptionCountByTopicUrl(dbCtx, topicUrl);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'one').throws(expected);
+      try {
+        await db.subscriptionCountByTopicUrl(dbCtx, topicUrl);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionCountByTopicUrl
+
+  describe('subscriptionDelete', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [ {} ],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      }
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.subscriptionDelete(dbCtx, callback, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'result').throws(expected);
+      try {
+        await db.subscriptionDelete(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionDelete
+
+  describe('subscriptionDeliveryClaim', function () {
+    it('success', async function() {
+      const dbResult = [
+        {
+          id: 'c2e254c5-aa6e-4a8f-b1a1-e474b07392bb',
+        },
+      ];
+      const expected = ['c2e254c5-aa6e-4a8f-b1a1-e474b07392bb'];
+      sinon.stub(db.db, 'manyOrNone').resolves(dbResult);
+      const result = await db.subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'manyOrNone').throws(expected);
+      try {
+        await db.subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant );
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionDeliveryClaim
+
+  describe('subscriptionDeliveryClaimById', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ id: 'c2e254c5-aa6e-4a8f-b1a1-e474b07392bb' }],
+        duration: 11,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: 'c2e254c5-aa6e-4a8f-b1a1-e474b07392bb',
+        duration: 11,
+      }
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.subscriptionDeliveryClaimById(dbCtx, subscriptionId, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 11,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.subscriptionDeliveryClaimById(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryClaimById
+
+  describe('subscriptionDeliveryComplete', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.subscriptionDeliveryComplete(dbCtx, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult);
+      try {
+        await db.subscriptionDeliveryComplete(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbResult0 = {
+        rowCount: 1,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.subscriptionDeliveryComplete(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryComplete
+
+  describe('subscriptionDeliveryGone', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.subscriptionDeliveryGone(dbCtx, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.subscriptionDeliveryGone(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryGone
+
+  describe('subscriptionDeliveryIncomplete', function () {
+    it('success', async function() {
+      const dbOne = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays);
+    });
+    it('success covers default', async function() {
+      const dbOne = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbOne = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbOne = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryIncomplete
+
+  describe('subscriptionGet', function () {
+    it('success', async function() {
+      const expected = {
+        id: subscriptionId,
+      };
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.subscriptionGet(dbCtx, callback, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.subscriptionGet(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionGet
+
+  describe('subscriptionGetById', function () {
+    it('success', async function() {
+      const expected = {
+        id: subscriptionId,
+      };
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.subscriptionGetById(dbCtx, subscriptionId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.subscriptionGetById(dbCtx, subscriptionId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionGetById
+
+  describe('subscriptionUpsert', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        callback,
+        topicId,
+        leaseSeconds,
+        secret,
+        httpRemoteAddr,
+        httpFrom,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ id: subscriptionId }],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: subscriptionId,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.subscriptionUpsert(dbCtx, data);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.subscriptionUpsert(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionUpsert
+
+  describe('subscriptionUpdate', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        signatureAlgorithm: 'sha256',
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.subscriptionUpdate(dbCtx, data);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.subscriptionUpdate(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionUpdate
+
+  describe('topicDeleted', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.topicDeleted(dbCtx, topicId);
+    });
+    it('failure', async function() {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicDeleted(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicDeleted
+
+  describe('topicFetchClaim', function () {
+    it('success', async function() {
+      const dbResult = [{ id: topicId }];
+      const expected = [topicId];
+      sinon.stub(db.db, 'manyOrNone').resolves(dbResult);
+      const result = await db.topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'manyOrNone').throws(expected);
+      try {
+        await db.topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicFetchClaim
+
+  describe('topicFetchClaimById', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.topicFetchClaimById(dbCtx, topicId, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'result').throws(expected);
+      try {
+        await db.topicFetchClaimById(dbCtx, topicId, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicFetchClaimById
+
+  describe('topicFetchComplete', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.topicFetchComplete(dbCtx, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicFetchComplete(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.topicFetchComplete(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchComplete
+
+  describe('topicFetchIncomplete', function () {
+    it('success', async function() {
+      const dbOne = { currentAttempt: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      }
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      const result = await db.topicFetchIncomplete(dbCtx, topicId, retryDelays);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers defaults', async function() {
+      const dbOne = { currentAttempt: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      }
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      const result = await db.topicFetchIncomplete(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbOne = { currentAttempt: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      }
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.topicFetchIncomplete(dbCtx, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbOne = { currentAttempt: 0 };
+      const dbResult0 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      }
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.topicFetchIncomplete(dbCtx, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchIncomplete
+
+  describe('topicFetchRequested', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.topicFetchRequested(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicFetchRequested(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchRequested
+
+  describe('topicGetAll', function () {
+    it('success', async function() {
+      const expected = [{ id: topicId }];
+      sinon.stub(db.db, 'manyOrNone').resolves(expected);
+      const result = await db.topicGetAll(dbCtx);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers default', async function() {
+      const expected = undefined;
+      sinon.stub(db.db, 'manyOrNone').resolves(expected);
+      const result = await db.topicGetAll(dbCtx);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'manyOrNone').throws(expected);
+      try {
+        await db.topicGetAll(dbCtx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetById
+
+  describe('topicGetById', function () {
+    it('success', async function() {
+      const expected = { id: topicId };
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers none', async function() {
+      const expected = undefined;
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers no defaults', async function () {
+      const expected = { id: topicId };
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetById(dbCtx, topicId, false);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.topicGetById(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetById
+
+  describe('topicGetByUrl', function () {
+    it('success', async function() {
+      const expected = [];
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetByUrl(dbCtx, topicUrl);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.topicGetByUrl(dbCtx, topicUrl);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetByUrl
+
+  describe('topicGetContentById', function () {
+    it('success', async function() {
+      const expected = { id: topicId };
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetContentById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers default', async function() {
+      const expected = undefined;
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetContentById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.topicGetContentById(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetContentById
+
+  describe('topicSet', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        url: topicUrl,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ id: topicId }],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: topicId,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.topicSet(dbCtx, data);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicSet(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('fails invalid value', async function () {
+      sinon.stub(db.db, 'result');
+      try {
+        data.leaseSecondsPreferred = -100;
+        await db.topicSet(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+      assert(!db.db.result.called);
+    });
+    it('fails invalid values', async function () {
+      sinon.stub(db.db, 'result');
+      try {
+        data.leaseSecondsPreferred = 10;
+        data.leaseSecondsMax = 100;
+        data.leaseSecondsMin = 50;
+        await db.topicSet(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+      assert(!db.db.result.called);
+    });
+  }); // topicSet
+
+  describe('topicSetContent', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        content: 'content',
+        contentType: 'text/plain',
+        contentHash: 'abc123',
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.topicSetContent(dbCtx, data);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicSetContent(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicSetContent
+
+  describe('topicUpdate', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        leaseSecondsPreferred: 123,
+        leaseSecondsMin: 100,
+        leaseSecondsMax: 1000,
+        publisherValidationUrl: null,
+        contentHashAlgorithm: 'sha256',
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.topicUpdate(dbCtx, data);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicUpdate(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+
+  });
+
+  describe('verificationClaim', function () {
+    it('success', async function() {
+      const dbManyOrNone = [{ id: verificationId }];
+      const expected = [verificationId];
+      sinon.stub(db.db, 'manyOrNone').resolves(dbManyOrNone);
+      const result = await db.verificationClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'manyOrNone').throws(expected);
+      try {
+        await db.verificationClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // verificationClaim
+
+  describe('verificationClaimById', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [ { id: verificationId } ],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: verificationId,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'result').throws(expected);
+      try {
+        await db.verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // verificationClaimById
+
+  describe('verificationComplete', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.verificationComplete(dbCtx, verificationId, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.verificationComplete(dbCtx, verificationId, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationComplete
+
+  describe('verificationGetById', function () {
+    it('success', async function() {
+      const dbOneOrNone = { id: verificationId };
+      const expected = { id: verificationId };
+      sinon.stub(db.db, 'oneOrNone').resolves(dbOneOrNone);
+      const result = await db.verificationGetById(dbCtx, verificationId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.verificationGetById(dbCtx, verificationId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // verificationGetById
+
+  describe('verificationIncomplete', function () {
+    it('success', async function() {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      await db.verificationIncomplete(dbCtx, verificationId, retryDelays);
+    });
+    it('covers defaults', async function() {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      await db.verificationIncomplete(dbCtx, verificationId);
+    });
+    it('failure', async function () {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.verificationIncomplete(dbCtx, verificationId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.verificationIncomplete(dbCtx, verificationId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationIncomplete
+
+  describe('verificationInsert', function () {
+    let verification;
+    beforeEach(function () {
+      verification = {
+        topicId,
+        callback,
+        mode: 'subscribe',
+        isPublisherValidated: true,
+        leaseSeconds: 86400,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ id: verificationId }],
+        duration: 10,
+      };
+      const expected = verificationId;
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.verificationInsert(dbCtx, verification);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.verificationInsert(dbCtx, verification);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('fails validation', async function () {
+      delete verification.leaseSeconds;
+      try {
+        await db.verificationInsert(dbCtx, verification);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+  }); // verificationInsert
+
+  describe('verificationRelease', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.verificationRelease(dbCtx, verificationId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.verificationRelease(dbCtx, verificationId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationRelease
+
+  describe('verificationUpdate', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        mode: 'subscribe',
+        isPublisherValidated: true,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.verificationUpdate(dbCtx, verificationId, data);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      }
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.verificationUpdate(dbCtx, verificationId, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult, e.name);
+      }
+    });
+    it('fails validation', async function () {
+      delete data.mode;
+      try {
+        await db.verificationUpdate(dbCtx, verificationId, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+  }); // verificationUpdate
+
+  describe('verificationValidated', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      }
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.verificationValidated(dbCtx, verificationId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      }
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.verificationValidated(dbCtx, verificationId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationValidated
+
+}); // DatabasePostgres