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>
202 lines
7.4 KiB
JavaScript
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();
|
|
});
|
|
});
|
|
});
|