From: Justin Wind Date: Sun, 13 Mar 2022 20:00:42 +0000 (-0700) Subject: display history of topic updates on topic details page X-Git-Tag: v1.3.6^2~8 X-Git-Url: https://git.squeep.com/?a=commitdiff_plain;h=737fbd003d5c4dfea81b667ef906f1c106a60612;p=websub-hub display history of topic updates on topic details page --- diff --git a/config/default.js b/config/default.js index b23d0f8..e4fd712 100644 --- a/config/default.js +++ b/config/default.js @@ -53,6 +53,7 @@ const defaultOptions = { 'Development Repository / GitHub mirror', '©', ], + publishHistoryDays: 60, // Number of days of update history to show on topic details page strictSecrets: false, // If true, reject requests with secrets but not over https publicHub: true, // Accept publish requests as new topics. processImmediately: true, // If true, immediately attempt to process requests when accepted. diff --git a/src/db/base.js b/src/db/base.js index de0cd44..fbc2cba 100644 --- a/src/db/base.js +++ b/src/db/base.js @@ -585,6 +585,17 @@ class Database { } + /** + * Return an array of the counts of the last #days of topic updates. + * @param {*} dbCtx + * @param {*} topicId + * @param {Number} days + */ + async topicPublishHistory(dbCtx, topicId, days) { + this._notImplemented('topicPublishHistory', arguments); + } + + /** * Create or update the basic parameters of a topic. * @param {*} dbCtx @@ -598,7 +609,7 @@ class Database { /** * Updates a topic's content data and content update timestamp. * @param {Object} data - * @param {Integer} data.topicId + * @param {*} data.topicId * @param {String} data.content * @param {String} data.contentHash * @param {String=} data.contentType diff --git a/src/db/postgres/index.js b/src/db/postgres/index.js index d950d4a..3e97336 100644 --- a/src/db/postgres/index.js +++ b/src/db/postgres/index.js @@ -856,6 +856,18 @@ class DatabasePostgres extends Database { } + async topicPublishHistory(dbCtx, topicId, days) { + const _scope = _fileScope('topicPublishHistory'); + this.logger.debug(_scope, 'called', { topicId, days }); + + const events = await dbCtx.manyOrNone(this.statement.topicPublishHistory, { topicIds: [topicId], daysAgo: days }); + const history = Array.from({ length: days }, () => 0); + events.forEach(({ daysAgo, contentUpdates }) => history[daysAgo] = contentUpdates); + + return history; + } + + async topicSet(dbCtx, data) { const _scope = _fileScope('topicSet'); this.logger.debug(_scope, 'called', data); diff --git a/src/db/postgres/sql/topic-publish-history.sql b/src/db/postgres/sql/topic-publish-history.sql new file mode 100644 index 0000000..e4010e8 --- /dev/null +++ b/src/db/postgres/sql/topic-publish-history.sql @@ -0,0 +1,12 @@ +-- +SELECT + EXTRACT(DAY FROM now() - content_updated::date) AS days_ago, + count(*) AS content_updates +FROM topic_content_history +WHERE + (now() - content_updated::date) < ($(daysAgo) * INTERVAL '1 day') +AND + ($(topicIds) IS NULL OR topic_id = ANY($(topicIds)::uuid[])) +GROUP BY content_updated::date +ORDER BY content_updated::date +; diff --git a/src/db/sqlite/index.js b/src/db/sqlite/index.js index b8826a4..cc4ea90 100644 --- a/src/db/sqlite/index.js +++ b/src/db/sqlite/index.js @@ -822,6 +822,18 @@ class DatabaseSQLite extends Database { } + topicPublishHistory(dbCtx, topicId, days) { + const _scope = _fileScope('topicPublishHistory'); + this.logger.debug(_scope, 'called', { topicId, days }) + + const events = this.statement.topicPublishHistory.all({ topicId, daysAgo: days }); + const history = Array.from({ length: days }, () => 0); + events.forEach(({ daysAgo, contentUpdates }) => history[daysAgo] = contentUpdates); + + return history; + } + + topicSet(dbCtx, data) { const _scope = _fileScope('topicSet'); this.logger.debug(_scope, 'called', data); diff --git a/src/db/sqlite/sql/topic-publish-history.sql b/src/db/sqlite/sql/topic-publish-history.sql new file mode 100644 index 0000000..184c449 --- /dev/null +++ b/src/db/sqlite/sql/topic-publish-history.sql @@ -0,0 +1,12 @@ +-- +SELECT + (strftime('%s', 'now') - content_updated) / 86400 AS days_ago, + count(*) AS content_updates +FROM topic_content_history +WHERE + days_ago <= :daysAgo +AND + (:topicId IS NULL OR topic_id = :topicId) +GROUP BY days_ago +ORDER BY days_ago +; diff --git a/src/manager.js b/src/manager.js index b5eff7a..c570671 100644 --- a/src/manager.js +++ b/src/manager.js @@ -544,10 +544,65 @@ class Manager { }); res.end(this.infoContent(ctx)); - this.logger.info(_scope, 'finished', { ...ctx }); + this.logger.info(_scope, 'finished', { ctx }); } + /** + * label the bars of the topic update history graph + * @param {Number} index + * @param {Number} value + * @returns {String} + */ + static _historyBarCaption(index, value) { + let when; + switch (index) { + case 0: + when ='today'; + break; + case 1: + when = 'yesterday'; + break; + default: + when = `${index} days ago`; + } + return `${when}, ${value ? value : 'no'} update${value === 1 ? '': 's'}`; + } + + + /** + * GET SVG chart of topic update history + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async getHistorySVG(res, ctx) { + const _scope = _fileScope('getHist'); + this.logger.debug(_scope, 'called', { ctx }); + + const days = Math.min(parseInt(ctx.queryParams.days) || this.options.manager.publishHistoryDays, 365); + const histOptions = { + title: 'Topic Publish History', + description: 'Updates per Day', + labelZero: '^ Today', + labelX: 'Days Ago', + maxItems: days, + minItems: days, + tickEvery: 7, + barWidth: 25, + barHeight: 40, + labelHeight: 12, + barCaptionFn: Manager._historyBarCaption, + }; + + let publishHistory; + await this.db.context(async (dbCtx) => { + publishHistory = await this.db.topicPublishHistory(dbCtx, ctx.params.topicId, days); + }); + + res.end(Template.histogramSVG(publishHistory, histOptions)); + this.logger.info(_scope, 'finished', { ctx }); + } + /** * GET request for authorized /admin information. * @param {http.ServerResponse} res @@ -572,7 +627,7 @@ class Manager { } res.end(Template.adminOverviewHTML(ctx, this.options)); - this.logger.info(_scope, 'finished', { ...ctx, topics: ctx.topics.length }) + this.logger.info(_scope, 'finished', { ctx, topics: ctx.topics.length }); } @@ -585,12 +640,17 @@ class Manager { const _scope = _fileScope('getTopicDetails'); this.logger.debug(_scope, 'called', { ctx }); + + ctx.publishSpan = 60; const topicId = ctx.params.topicId; + let publishHistory; await this.db.context(async (dbCtx) => { ctx.topic = await this.db.topicGetById(dbCtx, topicId); ctx.subscriptions = await this.db.subscriptionsByTopicId(dbCtx, topicId); + publishHistory = await this.db.topicPublishHistory(dbCtx, topicId, ctx.publishSpan); }); - this.logger.debug(_scope, 'got topic details', { topic: ctx.topic, subscriptions: ctx.subscriptions }); + ctx.publishCount = publishHistory.reduce((a, b) => a + b, 0); + this.logger.debug(_scope, 'got topic details', { topic: ctx.topic, subscriptions: ctx.subscriptions, updates: ctx.publishCount }); // Profile users can only see related topics. if (ctx.session && ctx.session.authenticatedProfile) { @@ -603,7 +663,7 @@ class Manager { } res.end(Template.adminTopicDetailsHTML(ctx, this.options)); - this.logger.info(_scope, 'finished', { ...ctx, subscriptions: ctx.subscriptions.length, topic: ctx.topic && ctx.topic.id || ctx.topic }); + this.logger.info(_scope, 'finished', { ctx, subscriptions: ctx.subscriptions.length, topic: ctx.topic && ctx.topic.id || ctx.topic }); } diff --git a/src/service.js b/src/service.js index 17fa134..df89401 100644 --- a/src/service.js +++ b/src/service.js @@ -52,6 +52,7 @@ class Service extends Dingus { this.on(['GET', 'HEAD'], '/admin', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/admin/`); this.on(['GET', 'HEAD'], '/admin/', this.handlerGetAdminOverview.bind(this)); this.on(['GET', 'HEAD'], '/admin/topic/:topicId', this.handlerGetAdminTopicDetails.bind(this)); + this.on(['GET', 'HEAD'], '/admin/topic/:topicId/history.svg', this.handlerGetHistorySVG.bind(this)); // Private data-editing endpoints this.on(['PATCH', 'DELETE'], '/admin/topic/:topicId', this.handlerUpdateTopic.bind(this)); @@ -161,6 +162,20 @@ class Service extends Dingus { } + async handlerGetHistorySVG(req, res, ctx) { + const _scope = _fileScope('handlerGetHist'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + const responseTypes = [Enum.ContentType.ImageSVG]; + + Service.setHeadHandler(req, res, ctx); + + this.setResponseType(responseTypes, req, res, ctx); + + await this.manager.getHistorySVG(res, ctx); + } + + /** * @param {http.ClientRequest} req * @param {http.ServerResponse} res diff --git a/src/template/admin-topic-details-html.js b/src/template/admin-topic-details-html.js index 8a20ec7..abf441a 100644 --- a/src/template/admin-topic-details-html.js +++ b/src/template/admin-topic-details-html.js @@ -44,6 +44,10 @@ module.exports = (ctx, options) => { ` `, + `
+

Topic Publish History — ${ctx.publishCount} updates in the last ${ctx.publishSpan} days

+ +
`, `

${ctx.subscriptions.length ? ctx.subscriptions.length : 'no'} subscription${(ctx.subscriptions.length === 1) ? '' : 's'}

diff --git a/src/template/badge-svg.js b/src/template/badge-svg.js index 681e072..3ef44d2 100644 --- a/src/template/badge-svg.js +++ b/src/template/badge-svg.js @@ -1,19 +1,6 @@ 'use strict'; -function escapeXml(s) { - if (typeof s === 'number') { - return s; - } else if (typeof s !== 'string') { - return undefined; - } else { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } -} +const th = require('./template-helper'); const ctxDefaults = { @@ -57,8 +44,8 @@ module.exports = (ctx, label, message, accessibleText) => { * renderer from https://github.com/badges/shields/tree/master/badge-maker which * is under the http://creativecommons.org/publicdomain/zero/1.0/ license. */ - return ` - ${escapeXml(ctx.accessibleText)} + return ` + ${th.xmlEscape(ctx.accessibleText)} @@ -74,8 +61,8 @@ module.exports = (ctx, label, message, accessibleText) => { - ${escapeXml(ctx.label)} - ${escapeXml(ctx.message)} + ${th.xmlEscape(ctx.label)} + ${th.xmlEscape(ctx.message)} `; }; diff --git a/src/template/histogram-svg.js b/src/template/histogram-svg.js new file mode 100644 index 0000000..13d0c91 --- /dev/null +++ b/src/template/histogram-svg.js @@ -0,0 +1,131 @@ +'use strict'; + +const th = require('./template-helper'); + +const optionsDefaults = { + barWidth: 20, + barHeight: 100, + scaleBars: true, + barCaptionFn: () => '', + labelZero: undefined, + labelX: undefined, + labelHeight: 8, + fontFamily: 'DejaVu Sans,Verdana,Geneva,sans-serif', + frameColor: 'gray', + tickEvery: undefined, + tickHeight: 4, + tickColor: 'gray', + minItems: 0, + maxItems: undefined, +}; + +function grey(percent) { + const value = Math.round(95 * (1.0 - percent)); + return `rgb(${value}%, ${value}%, ${value}%)`; +} + +function svgHeader(options, width, height) { + return [ + `', + options.title ? `${th.xmlEscape(options.title)}` : '', + options.description ? `${th.xmlEscape(options.description)}` : '', + '\n', + ]; +} + +function svgFrame(options, width) { + if (!options.frameColor) { + return ''; + } + return `\t +\t\t +\t\n`; +} + +function svgTicks(options, width) { + if (!options.tickEvery) { + return ''; + } + const tickSpacing = options.tickEvery * options.barWidth; + const tickOffset = options.barWidth / 2; + const numTicks = Math.ceil(width / tickSpacing); + const ticks = Array.from({ length: numTicks }, (_, i) => i) + .map((index) => `M ${(index * tickSpacing) + tickOffset} ${options.barHeight - 2} v ${options.tickHeight}`) + .join(' '); + + return `\t +\t +\t\n`; +} + +function svgLabels(options, width, height) { + const labels = []; + if (!options.labelHeight) { + return labels; + } + labels.push(`\t\n`); + const y = height - (options.labelHeight / 2) + 2; + if (options.labelZero) { + labels.push(`\t\t${options.labelZero}\n`); + } + if (options.labelX) { + labels.push(`\t\t${options.labelX}\n`); + } + labels.push('\t\n'); + return labels; +} + +function svgFooter() { + return ''; +} + +function svgBar(options, value, index, maxValue) { + const id = `i${index}`; + const x = options.barWidth * index; + const width = options.barWidth; + const height = options.barHeight; + const scale = value / Math.max(1, maxValue); + const scaleHeight = options.scaleBars ? height * scale : height; + const yOffset = height - scaleHeight; + const fill = grey(scale); + const emptyFill = grey(0); + const title = th.xmlEscape(options.barCaptionFn(index, value)); + return [ + ...(options.scaleBars && [ + `\t`, + ...(title && `${title}`), + '\t\n', + ]), + `\t`, + ...(title && `${title}`), + '\t\n', + ].join(''); +} + +module.exports = (items, options = {}) => { + options = Object.assign({}, optionsDefaults, options); + + const maxValue = items.reduce((a, b) => Math.max(a, b), 0); + const hasLabel = !!options.labelX || !!options.labelZero; + const height = options.barHeight + 2 + (hasLabel ? options.labelHeight + 2 : 0); + const width = Math.max(items.length, options.minItems) * options.barWidth; + + return [ + ...svgHeader(options, width, height), + ...items.slice(0, options.maxItems).map((value, index) => svgBar(options, value, index, maxValue)), + svgFrame(options, width), + svgTicks(options, width), + ...svgLabels(options, width, height), + svgFooter(), + ].join(''); +}; \ No newline at end of file diff --git a/src/template/index.js b/src/template/index.js index e177788..9ae40d2 100644 --- a/src/template/index.js +++ b/src/template/index.js @@ -4,5 +4,6 @@ module.exports = { adminOverviewHTML: require('./admin-overview-html'), adminTopicDetailsHTML: require('./admin-topic-details-html'), badgeSVG: require('./badge-svg'), + histogramSVG: require('./histogram-svg'), rootHTML: require('./root-html'), }; \ No newline at end of file diff --git a/src/template/template-helper.js b/src/template/template-helper.js index bb4dad1..a3e1131 100644 --- a/src/template/template-helper.js +++ b/src/template/template-helper.js @@ -2,7 +2,6 @@ const { TemplateHelper } = require('@squeep/html-template-helper'); - /** * Render a topic as a row of details. * @param {Object} topic @@ -112,7 +111,29 @@ function renderSubscriptionRowHeader() { } +/** + * Escape some xml things in strings. + * @param {String} string + */ +function xmlEscape(string) { + if (typeof string === 'number') { + return string; + } + if (typeof string !== 'string') { + return undefined; + } + // eslint-disable-next-line security/detect-object-injection + return string.replace(/[<>&'"]/, (c) => ({ + '<': '<', + '>': '>', + '&': '&', + '\'': ''', + '"': '"', + }[c])); +} + module.exports = Object.assign(Object.create(TemplateHelper), { + xmlEscape, renderTopicRowHeader, renderTopicRow, renderSubscriptionRowHeader, diff --git a/static/theme.css b/static/theme.css index fb85b52..f6099e2 100644 --- a/static/theme.css +++ b/static/theme.css @@ -69,6 +69,9 @@ section + section { } .topics {} .subscriptions {} +.history img { + width: 100%; +} .external { background-image: url("external-link.svg"); background-position: right center; diff --git a/test/src/db/base.js b/test/src/db/base.js index 7863657..74e5834 100644 --- a/test/src/db/base.js +++ b/test/src/db/base.js @@ -173,13 +173,13 @@ describe('DatabaseBase', function () { db._topicSetDataValidate(data); }); it('covers invalid value', function () { - data.leaseSecondsPreferred = -100; - try { - db._topicSetDataValidate(data); - assert.fail('did not get expected exception'); - } catch (e) { - assert(e instanceof DBErrors.DataValidation); - } + data.leaseSecondsPreferred = -100; + try { + db._topicSetDataValidate(data); + assert.fail('did not get expected exception'); + } catch (e) { + assert(e instanceof DBErrors.DataValidation); + } }); it('covers invalid range', function () { data.leaseSecondsPreferred = 10000; diff --git a/test/src/db/integration.js b/test/src/db/integration.js index 83feac7..b92202d 100644 --- a/test/src/db/integration.js +++ b/test/src/db/integration.js @@ -79,6 +79,11 @@ describe('Database Integration', function () { assert(db); }); + it('is healthy', async function () { + const result = await db.healthCheck(); + assert(result); + }); + describe('Authentication', function () { let identifier, credential; beforeEach(function () { @@ -220,6 +225,14 @@ describe('Database Integration', function () { assert.strictEqual(Number(topic.contentFetchAttemptsSinceSuccess), 0); }); }); + step('gets publish history', async function () { + await db.context(async (dbCtx) => { + const result = (await db.topicPublishHistory(dbCtx, topicId, 7)) + .map((x) => Number(x)); + const expected = [1, 0, 0, 0, 0, 0, 0]; + assert.deepStrictEqual(result, expected); + }); + }); step('deletes a topic', async function () { await db.context(async (dbCtx) => { const result = await db.topicSet(dbCtx, testData.anotherTopicSet); diff --git a/test/src/db/postgres.js b/test/src/db/postgres.js index da071f2..626b0c2 100644 --- a/test/src/db/postgres.js +++ b/test/src/db/postgres.js @@ -1322,6 +1322,21 @@ describe('DatabasePostgres', function () { }); }); + describe('topicPublishHistory', function () { + beforeEach(function () { + sinon.stub(db.db, 'manyOrNone'); + }); + it('success', async function () { + db.db.manyOrNone.returns([ + { daysAgo: 1, contentUpdates: 1 }, + { daysAgo: 3, contentUpdates: 2 }, + ]); + const result = await db.topicPublishHistory(dbCtx, topicId, 7); + const expected = [0, 1, 0, 2, 0, 0, 0]; + assert.deepStrictEqual(result, expected); + }); + }); // topicPublishHistory + describe('topicSet', function () { let data; beforeEach(function () { diff --git a/test/src/db/sqlite.js b/test/src/db/sqlite.js index be63994..deb0ee2 100644 --- a/test/src/db/sqlite.js +++ b/test/src/db/sqlite.js @@ -460,7 +460,7 @@ describe('DatabaseSQLite', function () { assert.deepStrictEqual(e, expected); } }); - }); + }); // subscriptionDeleteExpired describe('subscriptionDeliveryClaim', function () { it('success', async function () { @@ -1151,7 +1151,22 @@ describe('DatabaseSQLite', function () { } assert(db.statement.topicDeleteById.run.called); }); - }); + }); // topicPendingDelete + + describe('topicPublishHistory', function () { + beforeEach(function () { + sinon.stub(db.statement.topicPublishHistory, 'all'); + }); + it('success', function () { + db.statement.topicPublishHistory.all.returns([ + { daysAgo: 1, contentUpdates: 1 }, + { daysAgo: 3, contentUpdates: 2 }, + ]); + const result = db.topicPublishHistory(dbCtx, topicId, 7); + const expected = [0, 1, 0, 2, 0, 0, 0]; + assert.deepStrictEqual(result, expected); + }); + }); // topicPublishHistory describe('topicSet', function () { let data; diff --git a/test/src/manager.js b/test/src/manager.js index 67283d7..cbd8324 100644 --- a/test/src/manager.js +++ b/test/src/manager.js @@ -33,6 +33,7 @@ describe('Manager', function () { }; ctx = { params: {}, + queryParams: {}, }; manager = new Manager(stubLogger, stubDb, options); sinon.stub(manager.communication, 'verificationProcess'); @@ -137,6 +138,31 @@ describe('Manager', function () { }); }); // getInfo + describe('_historyBarCaption', function () { + it('covers today, none', function () { + const result = Manager._historyBarCaption(0, 0); + assert.strictEqual(result, 'today, no updates'); + }); + it('covers yesterday, singular', function () { + const result = Manager._historyBarCaption(1, 1); + assert.strictEqual(result, 'yesterday, 1 update'); + }); + it('covers older, plural', function () { + const result = Manager._historyBarCaption(7, 3); + assert.strictEqual(result, '7 days ago, 3 updates'); + }); + }); // _historyBarCaption + + describe('getHistorySVG', function () { + beforeEach(function () { + manager.db.topicPublishHistory.resolves([0, 1, 2, 1, 0, 1, 2, 0, 1]); + }); + it('covers', async function () { + await manager.getHistorySVG(res, ctx); + assert(res.end.called); + }); + }); // getHistorySVG + describe('getAdminOverview', function () { beforeEach(function () { manager.db.topicGetAll.resolves([ @@ -212,6 +238,7 @@ describe('Manager', function () { deliveryAttemptsSinceSuccess: 0, deliveryNextAttempt: new Date(-Infinity), }]); + manager.db.topicPublishHistory.resolves([0, 1, 0, 1, 0]); }); it('covers', async function() { await manager.getTopicDetails(res, ctx); diff --git a/test/src/service.js b/test/src/service.js index cafe1d1..bc74ff5 100644 --- a/test/src/service.js +++ b/test/src/service.js @@ -108,12 +108,19 @@ describe('Service', function () { }); // handlerGetHealthcheck describe('handlerGetInfo', function () { - it('covers', async function() { + it('covers', async function () { await service.handlerGetInfo(req, res, ctx); assert(service.manager.getInfo.called); }); }); // handlerGetInfo + describe('handlerGetHistorySVG', function () { + it('covers', async function () { + await service.handlerGetHistorySVG(req, res, ctx); + assert(service.manager.getHistorySVG.called); + }); + }); // handlerGetHistorySVG + describe('handlerGetAdminOverview', function () { it('covers', async function () { await service.handlerGetAdminOverview(req, res, ctx); diff --git a/test/src/template/admin-topic-details-html.js b/test/src/template/admin-topic-details-html.js index 823b439..7795c2b 100644 --- a/test/src/template/admin-topic-details-html.js +++ b/test/src/template/admin-topic-details-html.js @@ -11,6 +11,9 @@ describe('Admin Topic Details HTML Template', function () { beforeEach(function () { ctx = { + params: { + topicId: '97dd5488-a303-11ec-97ab-0025905f714a', + }, topic: {}, subscriptions: [ {}, diff --git a/test/src/template/histogram-svg.js b/test/src/template/histogram-svg.js new file mode 100644 index 0000000..656ccdb --- /dev/null +++ b/test/src/template/histogram-svg.js @@ -0,0 +1,29 @@ +/* eslint-env mocha */ +'use strict'; + +const assert = require('assert'); +const template = require('../../../src/template/histogram-svg'); + +describe('Histogram SVG Template', function () { + let items, options; + beforeEach(function () { + items = []; + options = {}; + }); + it('covers defaults', function () { + const result = template(items, options); + assert(result); + }); + it('covers options', function () { + items = [0, 1, 2]; + options = { + labelX: 'Days Ago', + labelZero: 'Today', + tickEvery: 2, + frameColor: undefined, + labelHeight: 0, + }; + const result = template(items, options); + assert(result); + }); +}); \ No newline at end of file diff --git a/test/src/template/template-helper.js b/test/src/template/template-helper.js index 8c8579d..5a62a7c 100644 --- a/test/src/template/template-helper.js +++ b/test/src/template/template-helper.js @@ -63,4 +63,19 @@ describe('Template Helper', function () { }); }); // renderSubscriptionRowHeader + describe('xmlEscape', function () { + it('ignores numbers', function () { + const result = th.xmlEscape(3); + assert.strictEqual(result, 3); + }); + it('ignores objects', function () { + const result = th.xmlEscape({}); + assert.strictEqual(result, undefined); + }); + it('escapes a thing', function () { + const result = th.xmlEscape('&'); + assert.strictEqual(result, '&'); + }); + }); // xmlEscape + }); diff --git a/test/stub-db.js b/test/stub-db.js index f257cbf..8ef1045 100644 --- a/test/stub-db.js +++ b/test/stub-db.js @@ -38,6 +38,7 @@ const stubFns = [ 'topicGetByUrl', 'topicGetContentById', 'topicPendingDelete', + 'topicPublishHistory', 'topicSet', 'topicSetContent', 'topicUpdate',