netanalyzer/tests/webexPhone.test.js
Joseph McQueen b3c37bd7df feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent
Rebrand NetAnalyzer -> StoreHealthAnalyzer and consolidate the store
reporting surface into a single `st [number]` command with focused
sub-modes.

Commands
- st [number]                - general info (SIW + brands + Meraki net link)
- st [number] network        - switches, APs, store server
- st [number] pos            - registers, payment terminals, customer display
- st [number] ios            - MDM-tracked iOS hardware
- st [number] phone          - wired 78xx + DECT basestations/handsets with
                               registration state, extensions and main DID
- st [number] av             - Atlas AMPs + MDM-tracked Apple TVs, video
                               walls, music players, LED displays
- Removed `analyze` in favor of the unified `st` surface

Integrations
- integrations/webex: Service App OAuth with rotating refresh tokens,
  seed + cleanup scripts, tokens/ storage (git-ignored)
- integrations/atlas: Xyte client + cached device discovery keyed on
  zero-padded 6-digit store numbers, cold-cache failure -> unavailable
  banner instead of a misleading empty result
- services/webexPhone, services/webexService, services/avService: shape
  raw upstream data into the report layer's contract
- utils/merakiMatcher: FQDN hostname extraction so payment terminals
  match Meraki descriptions; case-insensitive lookup
- utils/chunkReport: split long markdown replies at 7000-char boundaries

Reliability / ops
- server.js: awaited framework.stop() + 8s hard-kill timer so nodemon /
  Docker restarts don't leak WDM device registrations ("excessive device
  registrations")
- nodemon.json: SIGINT so the graceful path always runs
- scripts/cleanupWebexDevices.js: one-shot WDM cleanup utility
- Group-space routing: hears() regexes tolerate the leading @BotName
  prefix Webex prepends to mentions
- Replaced HTML-unsafe <number> placeholders with [number] in all help
  strings

Remote agent containerization
- docker/remote-agent/: multi-stage node:22-alpine image, non-root user,
  tini for signal handling, minimal deps (ws/axios/dotenv)
- docker/remote-agent/package.sh: docker buildx build defaulting to
  linux/amd64 (with override), saves image + assembles deploy/ + writes
  SHA256 + zips for offline transfer
- docker/remote-agent/deploy/: runtime docker-compose.yml, install.sh
  with platform sanity check, remote-host README
- .dockerignore + .gitignore updates for build artifacts and dist bundles
- npm run agent:package convenience script

Cleanup
- Dropped storeHealth.js / HealthReport.js and their tests/mocks in favor
  of the shared storeDetail pipeline
- Store model handles null SIW records gracefully; toSummary always
  ends with a newline so the Meraki link sits on its own line

Tests
- 144 tests across 14 suites passing; new coverage for atlasClient,
  atlasDevices, avService, avCategory classification, webexPhone,
  webexServiceAppAuth, storeDetail integration, siw, chunkReport and
  the updated meraki matcher

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 09:54:41 -04:00

202 lines
7.4 KiB
JavaScript

jest.mock('../config', () => ({
webexServiceApp: { clientId: 'c', clientSecret: 's', tokensPath: '/tmp/x.json' },
logLevel: 'error',
}));
jest.mock('../services/webexService', () => ({
request: jest.fn(),
}));
const webex = require('../services/webexService');
const phone = require('../services/webexPhone');
function setRouteResponses(routes) {
webex.request.mockReset();
webex.request.mockImplementation(async (method, pathSuffix, _body, _params) => {
const key = `${method} ${pathSuffix}`;
if (key in routes) {
const v = routes[key];
return typeof v === 'function' ? v() : v;
}
throw new Error(`Unmocked Webex request: ${key}`);
});
}
describe('webexPhone.storeEmail', () => {
it('pads to 5 digits and uses the @ae.com domain', () => {
expect(phone.storeEmail(782)).toBe('ae00782@ae.com');
expect(phone.storeEmail('00305')).toBe('ae00305@ae.com');
expect(phone.storeEmail(' 12345 ')).toBe('ae12345@ae.com');
});
});
describe('webexPhone.WIRED_PHONE_PATTERN', () => {
it('matches 7821 / 7841 and rejects everything else', () => {
expect(phone.WIRED_PHONE_PATTERN.test('Cisco IP Phone 7821')).toBe(true);
expect(phone.WIRED_PHONE_PATTERN.test('CP-7841-K9')).toBe(true);
expect(phone.WIRED_PHONE_PATTERN.test('Cisco 8851')).toBe(false);
expect(phone.WIRED_PHONE_PATTERN.test('Webex Desk Pro')).toBe(false);
});
});
describe('collectPhoneStatus', () => {
it('returns the full flat shape and associates handsets with their base', async () => {
setRouteResponses({
'GET people': { items: [{ id: 'person-1' }] },
'GET people/person-1': {
id: 'person-1',
displayName: 'AE Store 782',
extension: '50782',
},
'GET devices': {
items: [
{ mac: '11:22:33:44:55:66', product: 'Cisco 7841', displayName: 'Front Desk Phone' },
{
mac: 'aa:bb:cc:dd:ee:ff',
product: 'Cisco 8851',
displayName: 'Should be filtered out',
},
{ mac: '99:88:77:66:55:44', product: 'CP-7821-K9' },
],
},
'GET telephony/config/people/person-1/dectNetworks': {
dectNetworks: [
{
id: 'dn-1',
name: 'Store 0782',
location: { id: 'loc-1', name: 'Store 0782' },
numberOfHandsetsAssigned: 2,
},
],
},
'GET telephony/config/locations/loc-1/dectNetworks/dn-1/baseStations': {
items: [
{ id: 'base-1', mac: 'BA:5E:01:00:00:01', displayName: 'Base 1', status: 'online' },
{ id: 'base-2', mac: 'BA:5E:02:00:00:02', displayName: 'Base 2', status: 'online' },
],
},
'GET telephony/config/locations/loc-1/dectNetworks/dn-1/handsets': {
items: [
{ id: 'h-1', index: 1, defaultDisplayName: 'Handset 1', status: 'online' },
{ id: 'h-2', index: 2, defaultDisplayName: 'Handset 2', status: 'offline' },
{ id: 'h-3', index: 3, defaultDisplayName: 'Handset 3 (orphan)', status: 'unknown' },
],
},
'GET telephony/config/locations/loc-1/dectNetworks/dn-1/handsets/h-1': {
id: 'h-1',
index: 1,
baseStationId: 'base-1',
lines: [{ extension: '1001' }],
},
'GET telephony/config/locations/loc-1/dectNetworks/dn-1/handsets/h-2': {
id: 'h-2',
index: 2,
baseStationId: 'base-2',
lines: [{ extension: '1002' }],
},
'GET telephony/config/locations/loc-1/dectNetworks/dn-1/handsets/h-3': {
id: 'h-3',
index: 3,
baseStationId: null, // orphan
lines: [],
},
'GET telephony/config/locations/loc-1': {
callingLineId: { phoneNumber: '+15555550100' },
},
});
const result = await phone.collectPhoneStatus('782');
expect(result.unavailable).toBeFalsy();
expect(result.phones).toHaveLength(2); // 7841 + 7821, 8851 filtered out
expect(result.phones.map(p => p.model)).toEqual(['Cisco 7841', 'CP-7821-K9']);
// Every wired phone inherits the store-person extension.
expect(result.phones.every(p => p.extension === '50782')).toBe(true);
expect(result.basestations).toHaveLength(2);
expect(result.handsets).toHaveLength(3);
// Handset → base association via the detail endpoint
const h1 = result.handsets.find(h => h.id === 'h-1');
const h2 = result.handsets.find(h => h.id === 'h-2');
const h3 = result.handsets.find(h => h.id === 'h-3');
expect(h1.baseStationId).toBe('base-1');
expect(h2.baseStationId).toBe('base-2');
expect(h3.baseStationId).toBeNull();
expect(h1.extension).toBe('1001');
// The handset's slot index flows through from both list and detail
// endpoints so the renderer can display "<index>-<extension>".
expect(h1.index).toBe(1);
expect(h2.index).toBe(2);
expect(h3.index).toBe(3);
expect(result.locationMainNumber).toBe('+15555550100');
expect(result.dectNetwork?.id).toBe('dn-1');
});
it('returns unavailable=true when no person exists for the store email', async () => {
setRouteResponses({
'GET people': { items: [] },
});
const result = await phone.collectPhoneStatus('999');
expect(result.unavailable).toBe(true);
expect(result.reason).toMatch(/ae00999@ae\.com/);
});
it('returns unavailable=true when the underlying request layer throws on first call', async () => {
webex.request.mockReset();
webex.request.mockRejectedValue(new Error('WEBEX_CLIENT_ID is required'));
const result = await phone.collectPhoneStatus('782');
// collectPhoneStatus swallows getPersonIdByEmail errors and returns
// "no person" rather than crashing — equivalent surface to "unavailable".
expect(result.unavailable).toBe(true);
});
it('handles a store with no DECT network gracefully (wired phones only)', async () => {
setRouteResponses({
'GET people': { items: [{ id: 'person-2' }] },
'GET people/person-2': { id: 'person-2', extension: '50305' },
'GET devices': { items: [{ mac: '11:22:33:44:55:66', product: 'Cisco 7841' }] },
'GET telephony/config/people/person-2/dectNetworks': { dectNetworks: [] },
});
const result = await phone.collectPhoneStatus('305');
expect(result.phones).toHaveLength(1);
expect(result.phones[0].extension).toBe('50305');
expect(result.basestations).toEqual([]);
expect(result.handsets).toEqual([]);
expect(result.dectNetwork).toBeNull();
expect(result.locationMainNumber).toBeNull();
});
describe('getPersonExtension', () => {
it('returns extension from the top-level extension field', async () => {
setRouteResponses({
'GET people/p1': { id: 'p1', extension: '50782' },
});
expect(await phone.getPersonExtension('p1')).toBe('50782');
});
it('falls back to phoneNumbers entry with extension-like type', async () => {
setRouteResponses({
'GET people/p2': {
id: 'p2',
phoneNumbers: [
{ type: 'work', value: '+14123694426' },
{ type: 'work_extension', value: '50305' },
],
},
});
expect(await phone.getPersonExtension('p2')).toBe('50305');
});
it('returns null when nothing extension-like is present', async () => {
setRouteResponses({
'GET people/p3': { id: 'p3', phoneNumbers: [{ type: 'work', value: '+1...' }] },
});
expect(await phone.getPersonExtension('p3')).toBeNull();
});
});
});