display history of topic updates on topic details page
authorJustin Wind <justin.wind+git@gmail.com>
Sun, 13 Mar 2022 20:00:42 +0000 (13:00 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Sun, 13 Mar 2022 20:00:42 +0000 (13:00 -0700)
24 files changed:
config/default.js
src/db/base.js
src/db/postgres/index.js
src/db/postgres/sql/topic-publish-history.sql [new file with mode: 0644]
src/db/sqlite/index.js
src/db/sqlite/sql/topic-publish-history.sql [new file with mode: 0644]
src/manager.js
src/service.js
src/template/admin-topic-details-html.js
src/template/badge-svg.js
src/template/histogram-svg.js [new file with mode: 0644]
src/template/index.js
src/template/template-helper.js
static/theme.css
test/src/db/base.js
test/src/db/integration.js
test/src/db/postgres.js
test/src/db/sqlite.js
test/src/manager.js
test/src/service.js
test/src/template/admin-topic-details-html.js
test/src/template/histogram-svg.js [new file with mode: 0644]
test/src/template/template-helper.js
test/stub-db.js

index b23d0f8833919fdd29af7480abd552f9f3b74b30..e4fd7129d046ea9fc3d52a29f1c7b8f256883577 100644 (file)
@@ -53,6 +53,7 @@ const defaultOptions = {
       '<a href="https://git.squeep.com/?p=websub-hub;a=tree">Development Repository</a> / <a href="https://github.com/thylacine/websub-hub/">GitHub mirror</a>',
       '<span class="copyright">&copy;<time datetime="2022">&#8559;&#8559;&#8553;&#8553;&#8544;&#8544;</time></span>',
     ],
+    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.
index de0cd44a1b9c5a77eb0e8fcb22771ffbf7c488c1..fbc2cbae0c2f83b2e79d5bc1a377ea5d8ca35a90 100644 (file)
@@ -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
index d950d4a8488924ea2999a9c03b03d93f38ab4591..3e97336992999adaee4a077c1f7ef9ddc7719a87 100644 (file)
@@ -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 (file)
index 0000000..e4010e8
--- /dev/null
@@ -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
+;
index b8826a4f15ee424113e72fd790b41b8212bf3cf8..cc4ea90af056db2c99f747f2705e25829801749a 100644 (file)
@@ -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 (file)
index 0000000..184c449
--- /dev/null
@@ -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
+;
index b5eff7a3ecb88698beeb9d4bcc9364f4ccedacb7..c570671d94ada2bad567fc02f5598d923d90b04b 100644 (file)
@@ -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 });
   }
 
 
index 17fa134d738447deb29aa5e889aaf806cb1443f8..df894011dc84f7cad424467b9e62720d4890f9d4 100644 (file)
@@ -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
index 8a20ec7db96119bf64d2bfcc4f0204634b5c83b5..abf441adfe8178d5d2a1104e0b848791fc023c16 100644 (file)
@@ -44,6 +44,10 @@ module.exports = (ctx, options) => {
     `        </tbody>
         </table>
       </section>`,
+    `      <section class="history">
+        <p>Topic Publish History &mdash; ${ctx.publishCount} updates in the last ${ctx.publishSpan} days</p>
+        <img title="Topic Publish History" src="${ctx.params.topicId}/history.svg">
+      </section>`,
     `      <section class="subscriptions">
         <p>${ctx.subscriptions.length ? ctx.subscriptions.length : 'no'} subscription${(ctx.subscriptions.length === 1) ? '' : 's'}</p>
         <table>
index 681e0720a2c3a001745655a4426a0a3123387c83..3ef44d26533b1eb8247104c55bc6ab34e7396f36 100644 (file)
@@ -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, '&amp;')
-      .replace(/</g, '&lt;')
-      .replace(/>/g, '&gt;')
-      .replace(/"/g, '&quot;')
-      .replace(/'/g, '&apos;');
-  }
-}
+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 `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${ctx.width}" height="${ctx.height}" role="img" aria-label="${escapeXml(ctx.accessibleText)}">
-  <title>${escapeXml(ctx.accessibleText)}</title>
+  return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${ctx.width}" height="${ctx.height}" role="img" aria-label="${th.xmlEscape(ctx.accessibleText)}">
+  <title>${th.xmlEscape(ctx.accessibleText)}</title>
   <linearGradient id="s" x2="0" y2="100%">
     <stop offset="0"  stop-color="#fff" stop-opacity=".7"/>
     <stop offset=".1" stop-color="#aaa" stop-opacity=".1"/>
@@ -74,8 +61,8 @@ module.exports = (ctx, label, message, accessibleText) => {
     <rect width="${ctx.width}" height="${ctx.height}" fill="url(#s)"/>
   </g>
   <g fill="${ctx.color}" text-anchor="left" font-family="${ctx.fontFamily}" text-rendering="geometricPrecision" font-size="11" font-weight="bold">
-    <text x="${ctx.halfCharWidth}" y="${ctx.verticalMargin}">${escapeXml(ctx.label)}</text>
-    <text x="${fixedRound(ctx.halfCharWidth + ctx.labelWidth)}" y="${ctx.verticalMargin}">${escapeXml(ctx.message)}</text>
+    <text x="${ctx.halfCharWidth}" y="${ctx.verticalMargin}">${th.xmlEscape(ctx.label)}</text>
+    <text x="${fixedRound(ctx.halfCharWidth + ctx.labelWidth)}" y="${ctx.verticalMargin}">${th.xmlEscape(ctx.message)}</text>
   </g>
 </svg>`;
 };
diff --git a/src/template/histogram-svg.js b/src/template/histogram-svg.js
new file mode 100644 (file)
index 0000000..13d0c91
--- /dev/null
@@ -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 [
+    `<svg
+version="1.1"
+baseProfile="full"
+xmlns="http://www.w3.org/2000/svg"
+xmlns:xlink="http://www.w3.org/1999/xlink"
+xmlns:ev="http://www.w3.org/2001/xml-events"
+width="${width}px" height="${height}px"
+viewBox="0 0 ${width} ${height}"
+preserveAspectRatio="xMinYMin meet"`,
+    options.accessibleText ? ` role="img" aria-label="${th.xmlEscape(options.accessibleText)}"` : '',
+    '>',
+    options.title ? `<title>${th.xmlEscape(options.title)}</title>` : '',
+    options.description ? `<desc>${th.xmlEscape(options.description)}</desc>` : '',
+    '\n',
+  ];
+}
+
+function svgFrame(options, width) {
+  if (!options.frameColor) {
+    return '';
+  }
+  return `\t<g id="frame">
+\t\t<rect x="0.5px" y="0.5px" width="${width - 1}px" height="${options.barHeight - 1}px" fill="none" stroke="${options.frameColor}" />
+\t</g>\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<g id="ticks">
+\t<path d="${ticks}" stroke="${options.tickColor}" fill="none" stroke-width="0.5px" />
+\t</g>\n`;
+}
+
+function svgLabels(options, width, height) {
+  const labels = [];
+  if (!options.labelHeight) {
+    return labels;
+  }
+  labels.push(`\t<g font-size="${options.labelHeight}px" font-family="${options.fontFamily}" font-variant="small-caps">\n`);
+  const y = height - (options.labelHeight / 2) + 2;
+  if (options.labelZero) {
+    labels.push(`\t\t<text font-size="${options.labelHeight}px" text-anchor="start" x="0" y="${y}">${options.labelZero}</text>\n`);
+  }
+  if (options.labelX) {
+    labels.push(`\t\t<text text-anchor="middle" x="${width / 2}" y="${y}">${options.labelX}</text>\n`);
+  }
+  labels.push('\t</g>\n');
+  return labels;
+}
+
+function svgFooter() {
+  return '</svg>';
+}
+
+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<rect id="${id}" x="${x}" y="0" width="${width}" height="${height}" fill="${emptyFill}">`,
+      ...(title && `<title>${title}</title>`),
+      '\t</rect>\n',
+    ]),
+    `\t<rect id="${id}" x="${x}" y="${yOffset}" width="${width}" height="${scaleHeight}" fill="${fill}">`,
+    ...(title && `<title>${title}</title>`),
+    '\t</rect>\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
index e177788079d180ccf598fbdef9a04b400fce5784..9ae40d25709502d9a333f200b2d73e8fe6f08a24 100644 (file)
@@ -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
index bb4dad1e3f574f2332683c9e4bc990c9bc98b3a2..a3e113179928d3a878400c9b9c513961a930f2c9 100644 (file)
@@ -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) => ({
+    '<': '&lt;',
+    '>': '&gt;',
+    '&': '&amp;',
+    '\'': '&apos;',
+    '"': '&quot;',
+  }[c]));
+}
+
 module.exports = Object.assign(Object.create(TemplateHelper), {
+  xmlEscape,
   renderTopicRowHeader,
   renderTopicRow,
   renderSubscriptionRowHeader,
index fb85b52b1834bd91ae5ed426d585fd0eb65780a9..f6099e2a74e8bcacac6858ec77daf3bf82e413b5 100644 (file)
@@ -69,6 +69,9 @@ section + section {
 }
 .topics {}
 .subscriptions {}
+.history img {
+       width: 100%;
+}
 .external {
        background-image: url("external-link.svg");
        background-position: right center;
index 786365722b13880f698b4f46184b3d4f3749bda2..74e58349159aecb7ce05f8210fe83d51e847b2bd 100644 (file)
@@ -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;
index 83feac761222a620793cb6963b9a2604e3fb60f5..b92202d41b0e30a04e759872ee984e756f95a13c 100644 (file)
@@ -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);
index da071f2ab28566b53b73ba3ff25858c7e96e0355..626b0c2573a145f1727e6cc5e099e06e16d1cdc1 100644 (file)
@@ -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 () {
index be6399445bcb01279ca2c2b9392c58cbf94e934c..deb0ee244fbbb0811f0f6b62b66bebe41aac8ff6 100644 (file)
@@ -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;
index 67283d76b82c992058e39b2926bd699cd1d43830..cbd83243d3faecabdf8d77667151a230ac180f88 100644 (file)
@@ -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);
index cafe1d16922ff9e4e0f48b19921a572e39d7ea2f..bc74ff5c6dac6faff30fa8e14928c21e7d3e5285 100644 (file)
@@ -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);
index 823b439d695ecbe41566b40d91a38382fae433b4..7795c2b257e849cd9294903d08255f5880f832d7 100644 (file)
@@ -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 (file)
index 0000000..656ccdb
--- /dev/null
@@ -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
index 8c8579d1cc5a83b3d65b8acd2c232805d61097a8..5a62a7c4b15f4d68a9c12f1a61461eda6c8c520d 100644 (file)
@@ -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, '&amp;');
+    });
+  }); // xmlEscape
+
 });
index f257cbfeeb0728af0b9b1bd135e49e7bb505d4c2..8ef104547c5907ff7c5dfe093e04d360d33895fd 100644 (file)
@@ -38,6 +38,7 @@ const stubFns = [
   'topicGetByUrl',
   'topicGetContentById',
   'topicPendingDelete',
+  'topicPublishHistory',
   'topicSet',
   'topicSetContent',
   'topicUpdate',