Multi-integration Webex chat/HTTP bot that unifies phone, AV, and network status for retail store support. Consolidates data from Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital signage, and OptiSigns into rich per-store status commands. Key surfaces: - /phonestatus, /avstatus — per-store phone & AV device reports with clickable Meraki deep-links and per-port detail. - /webexhost — check/assign Webex Meetings host licenses via the Service App; adaptive-card confirmation flow, HTTP-API-gated. - /offboarduser — full Webex Admin offboarding (auth revoke, device wipe, license removal); adaptive-card confirmation. - /jirapoll — on-demand trigger for the hourly Jira poller. - /bulkavstatuscsv — bulk store CSV export with concurrency limits. Automation: - Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier that categorizes unassigned tickets as phone/av/skip, extracts store numbers from free-text, and enriches Jira with the same detailed markdown the chat commands emit (converted to Jira ADF, preserves bold + Meraki links). Idempotent via a `bot-enriched` Jira label. Architecture: - Node.js 20+, ESM, Express 5, webex-node-bot-framework. - Layered integrations (integrations/*), services (services/*), commands (commands/*), utils (utils/*). - Shared markdown renderers (services/renderers/*) feed both chat handlers and the Jira poller so the two surfaces stay in sync. - Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) — no new npm dependency. - Node built-in test runner (`node --test tests/*.test.js`), 30 tests covering the converter, renderers, and poller ADF assembly. Docker + docker-compose deployment. Config via .env (see .env.example for the full option surface).
122 lines
4.4 KiB
JavaScript
122 lines
4.4 KiB
JavaScript
// Integration smoke test for the poller's ADF assembly.
|
|
//
|
|
// We can't (and shouldn't) fire the whole `pollNewTickets` from a unit
|
|
// test — it needs Jira credentials, the AI classifier, and Webex. So
|
|
// we test the two composed pieces the plan says matter: (1) the new
|
|
// `buildAdfComment` shape, and (2) end-to-end that a Meraki `[..](..)`
|
|
// link the renderer emits actually survives the markdown → ADF trip
|
|
// and lands as a `link` mark in the poster body.
|
|
//
|
|
// `buildAdfComment` was extracted to utils/adfComment.js — a pure
|
|
// module with no side-effect imports — precisely so tests can pull it
|
|
// in without needing to satisfy Jira/Webex/Grok env vars at load time.
|
|
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { buildAdfComment } from '../utils/adfComment.js';
|
|
import { markdownToAdfContent } from '../utils/markdownToAdf.js';
|
|
import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js';
|
|
|
|
test('buildAdfComment shape: italic header + spliced body nodes', () => {
|
|
const body = markdownToAdfContent('hello **bold** world');
|
|
const doc = buildAdfComment({
|
|
headerLine: 'Auto-enriched by CollabFinder — sample',
|
|
bodyNodes: body,
|
|
});
|
|
|
|
assert.equal(doc.version, 1);
|
|
assert.equal(doc.type, 'doc');
|
|
assert.equal(doc.content.length, 2);
|
|
|
|
const header = doc.content[0];
|
|
assert.equal(header.type, 'paragraph');
|
|
assert.equal(header.content[0].marks[0].type, 'em');
|
|
assert.equal(header.content[0].text, 'Auto-enriched by CollabFinder — sample');
|
|
|
|
assert.equal(doc.content[1].type, 'paragraph');
|
|
const strong = doc.content[1].content.find((n) => n.marks?.[0]?.type === 'strong');
|
|
assert.ok(strong, 'expected a strong-marked text node in body');
|
|
assert.equal(strong.text, 'bold');
|
|
});
|
|
|
|
test('buildAdfComment tolerates empty bodyNodes', () => {
|
|
const doc = buildAdfComment({ headerLine: 'header only' });
|
|
assert.equal(doc.content.length, 1);
|
|
assert.equal(doc.content[0].content[0].text, 'header only');
|
|
});
|
|
|
|
test('end-to-end: Meraki [Meraki↗](url) survives to a link mark in ADF', () => {
|
|
const threeHrAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
|
const data = {
|
|
phones: {
|
|
data: [{
|
|
displayName: 'PHONE 782-1',
|
|
status: 'connected',
|
|
lastSeen: threeHrAgo,
|
|
meraki: {
|
|
switchName: 'STORE-782-SW1',
|
|
status: 'Online',
|
|
port: '17',
|
|
vlan: '20',
|
|
ip: '10.1.1.5',
|
|
lastSeen: threeHrAgo,
|
|
clientUrl: 'https://n123.meraki.com/example/manage/clients/abc/overview',
|
|
},
|
|
}],
|
|
},
|
|
};
|
|
|
|
const markdown = renderPhoneStatusMarkdown(data, {
|
|
storeNum: '782',
|
|
detailed: true,
|
|
footer: false,
|
|
});
|
|
const bodyNodes = markdownToAdfContent(markdown);
|
|
const doc = buildAdfComment({ headerLine: 'test', bodyNodes });
|
|
|
|
const allTextNodes = collectTextNodes(doc);
|
|
const linkNodes = allTextNodes.filter((n) => n.marks?.some((m) => m.type === 'link'));
|
|
|
|
assert.ok(linkNodes.length > 0, 'expected at least one link mark in the ADF output');
|
|
const merakiLink = linkNodes.find((n) =>
|
|
n.marks.some((m) =>
|
|
m.type === 'link' &&
|
|
m.attrs?.href === 'https://n123.meraki.com/example/manage/clients/abc/overview',
|
|
),
|
|
);
|
|
assert.ok(merakiLink, 'expected the Meraki↗ URL to be preserved as a link mark');
|
|
assert.equal(merakiLink.text, 'Meraki↗');
|
|
});
|
|
|
|
test('end-to-end: bold device names survive to strong marks in ADF', () => {
|
|
const data = {
|
|
phones: {
|
|
data: [{
|
|
displayName: 'PHONE 782-99',
|
|
status: 'connected',
|
|
lastSeen: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
|
}],
|
|
},
|
|
};
|
|
const markdown = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false });
|
|
const nodes = markdownToAdfContent(markdown);
|
|
const all = collectTextNodes({ content: nodes });
|
|
const strongNames = all.filter((n) => n.marks?.some((m) => m.type === 'strong'));
|
|
assert.ok(
|
|
strongNames.some((n) => n.text === 'PHONE 782-99'),
|
|
'expected device name to appear as strong-marked text',
|
|
);
|
|
});
|
|
|
|
// Walk an ADF doc (or partial fragment) and return every leaf text node.
|
|
function collectTextNodes(node, acc = []) {
|
|
if (!node) return acc;
|
|
if (node.type === 'text') {
|
|
acc.push(node);
|
|
return acc;
|
|
}
|
|
const children = Array.isArray(node.content) ? node.content : [];
|
|
for (const child of children) collectTextNodes(child, acc);
|
|
return acc;
|
|
}
|