Files
ProdDash/modules/overflow-tap/keywords.selftest.js
T
Waters ChurchandClaude Fable 5.1 459d8b9c38 Overflow Tap: speak Overflow's Dashboard API, as its own helper app does
The first cut guessed a write route on Overflow's public API, which documents
only reads for Tap. With the helper app's bundle in hand (Overflow ProPresenter
Integration 1.2.3, read 2026-10-11) the module now makes exactly the calls the
helper makes, against the Dashboard's own backend:

  POST /nonprofit-dashboard/authentication/login/jwt      a Dashboard sign-in → JWT
  GET  /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords
                                                          the Keyword Mappings
  POST …/nonprofits/{id}/keywords?syncNonprofit=true      { keywords } = the switch

So the keyword → destination → Tap groups mapping stays where Overflow keeps
it (Dashboard › Integrations › ProPresenter › Keyword Mappings) and is read
from there: on start, every five minutes, on the tile's refresh button, and
from every trigger's answer. Admin config shrinks to the ProPresenter endpoint,
the Overflow Dashboard login (plus a Nonprofit ID only for an @overflow.co
staff login) and the alert switch; the API-key, Tap-groups, keyword-text and
match-mode fields are gone.

Behaviour follows the helper: both the presentation layer (/v1/status/slide)
and the announcement layer (/v1/announcement/slide_index + /active) are
watched; when a layer's set of matched keywords changes to a non-empty set it
is sent; matching is per word, case-insensitive, punctuation around the word
ignored. The session token is cached across re-inits for the same login so an
admin saving config repeatedly cannot trip Overflow's "too many attempts";
a 401 signs in again once; a refused sign-in is not retried within 20 s.

tools/overflow-tap-mock.js now plays the Dashboard API (any email/password
signs in; --fail-switch, --expire=<s>, --login=<email>:<password>) and gives
the mock ProPresenter an announcement layer (--no-announcements for an older
one). Verified against it: sign-in, keyword load, triggers on both layers,
token expiry → re-login, 500 → NOT SWITCHED + alert + retries, wrong login →
the reason on the tile and in the health line, and recovery. keywords.selftest.js
pins the matching rule (8 cases).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 10:25:02 -04:00

77 lines
2.8 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Self-test for keywords.js — the matching rules, pinned.
*
* node modules/overflow-tap/keywords.selftest.js
*/
const assert = require('node:assert/strict');
const { keywordMatches, matchKeywords, sameSet, cleanNotes } = require('./keywords');
let n = 0;
function test(name, fn) {
n += 1;
try {
fn();
console.log(`ok ${name}`);
} catch (err) {
console.log(`FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
test('a keyword fires as a word of the notes, whatever the case', () => {
assert.equal(keywordMatches('give', 'give'), true);
assert.equal(keywordMatches('Give', 'give'), true);
assert.equal(keywordMatches('please GIVE today', 'give'), true);
});
test('surrounding punctuation is ignored, as the helper strips it', () => {
assert.equal(keywordMatches('give!', 'give'), true);
assert.equal(keywordMatches('#give now', 'give'), true);
assert.equal(keywordMatches('go to (give) now', 'give'), true);
assert.equal(keywordMatches('"give"', 'give'), true);
assert.equal(keywordMatches('give.', 'give'), true);
});
test('a keyword inside another word does not fire', () => {
assert.equal(keywordMatches('a sermon on forgiveness', 'give'), false);
assert.equal(keywordMatches('giveaway', 'give'), false);
assert.equal(keywordMatches('give_now', 'give'), false);
});
test('a phrase keyword is bounded the same way', () => {
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps'), true);
assert.equal(keywordMatches('nextsteps', 'next steps'), false);
});
test('regex characters in a keyword are literal', () => {
assert.equal(keywordMatches('give.now', 'give.now'), true);
assert.equal(keywordMatches('giveXnow', 'give.now'), false);
});
test('empty notes never match; zero-width padding is ignored', () => {
assert.equal(keywordMatches('', 'give'), false);
assert.equal(keywordMatches('​', 'give'), false);
assert.equal(cleanNotes('​give'), 'give');
assert.equal(keywordMatches('​give', 'give'), true);
});
test('matchKeywords returns every hit, in list order, once each', () => {
const list = ['connect', 'give', 'prayer', { keyword: 'Events' }];
assert.deepEqual(matchKeywords('give and connect, then events', list), ['connect', 'give', 'Events']);
assert.deepEqual(matchKeywords('just worship', list), []);
assert.deepEqual(matchKeywords('give', []), []);
assert.deepEqual(matchKeywords('give give', ['give', 'GIVE']), ['give']);
});
test('sameSet compares keyword lists regardless of order and case', () => {
assert.equal(sameSet(['give', 'connect'], ['Connect', 'give']), true);
assert.equal(sameSet(['give'], ['give', 'connect']), false);
assert.equal(sameSet([], []), true);
assert.equal(sameSet(['give'], []), false);
});
console.log(`${n} tests, exit ${process.exitCode || 0}`);