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>
186 lines
6.6 KiB
JavaScript
186 lines
6.6 KiB
JavaScript
const {
|
|
findMatchingClient,
|
|
getClientStatus,
|
|
formatLastSeen,
|
|
buildMerakiClientLink,
|
|
extractHostname,
|
|
normalizeMac,
|
|
} = require('../utils/merakiMatcher');
|
|
|
|
describe('merakiMatcher', () => {
|
|
const sampleClients = [
|
|
{ id: 'c1', description: 'Register 305', status: 'Online', lastSeen: '2025-01-01T10:00:00Z' },
|
|
{
|
|
id: 'c2',
|
|
description: 'Printer Front',
|
|
status: 'Offline',
|
|
lastSeen: Date.now() - 5 * 60 * 1000,
|
|
},
|
|
{ id: 'c3', description: '192.168.10.45 - Terminal', status: 'Online', lastSeen: null },
|
|
{ id: 'c4', description: 'SRV-042', status: 'Online' },
|
|
];
|
|
|
|
describe('findMatchingClient', () => {
|
|
it('matches by exact or substring on description', () => {
|
|
expect(findMatchingClient(sampleClients, { name: 'Register 305' })).toBe(sampleClients[0]);
|
|
expect(findMatchingClient(sampleClients, { name: 'printer' })).toBe(sampleClients[1]);
|
|
});
|
|
|
|
it('matches using multiple identifier fields', () => {
|
|
const match = findMatchingClient(sampleClients, {
|
|
deviceName: 'Terminal',
|
|
ip_address: '192.168.10.45',
|
|
});
|
|
expect(match).toBe(sampleClients[2]);
|
|
});
|
|
|
|
it('matches MDM style UserName / DeviceFriendlyName', () => {
|
|
const match = findMatchingClient(sampleClients, {
|
|
UserName: 'SRV-042',
|
|
DeviceFriendlyName: 'Server 042',
|
|
});
|
|
expect(match).toBe(sampleClients[3]);
|
|
});
|
|
|
|
it('returns null when no clients or no match', () => {
|
|
expect(findMatchingClient([], { name: 'foo' })).toBeNull();
|
|
expect(findMatchingClient(sampleClients, { name: 'nonexistent' })).toBeNull();
|
|
});
|
|
|
|
// Payment-terminal regression: SIW reports the FQDN in `ip_address`
|
|
// (e.g. "VFI-807-005-168.us000782.stores.ae.com") and Meraki advertises
|
|
// only the lowercase hostname before the first dot. Before the fix the
|
|
// matcher compared an upper-case ipPrefix against a lower-cased desc and
|
|
// missed every payment terminal.
|
|
it('matches a Meraki client when SIW gives an FQDN ip_address (case-insensitive)', () => {
|
|
const clients = [
|
|
{ id: 'm1', description: 'vfi-807-005-168', status: 'Online', lastSeen: null },
|
|
];
|
|
const match = findMatchingClient(clients, {
|
|
deviceName: 'Terminal 11',
|
|
adyenName: 'P400Plus-807005168',
|
|
ip_address: 'VFI-807-005-168.us000782.stores.ae.com',
|
|
});
|
|
expect(match).toBe(clients[0]);
|
|
});
|
|
});
|
|
|
|
describe('normalizeMac', () => {
|
|
it('strips common separators and lowercases', () => {
|
|
expect(normalizeMac('AA:BB:CC:11:22:33')).toBe('aabbcc112233');
|
|
expect(normalizeMac('aa-bb-cc-11-22-33')).toBe('aabbcc112233');
|
|
expect(normalizeMac('aabb.cc11.2233')).toBe('aabbcc112233');
|
|
expect(normalizeMac('AABBCC112233')).toBe('aabbcc112233');
|
|
});
|
|
|
|
it('returns null for non-12-hex input', () => {
|
|
expect(normalizeMac('')).toBeNull();
|
|
expect(normalizeMac(null)).toBeNull();
|
|
expect(normalizeMac('aa:bb:cc')).toBeNull();
|
|
expect(normalizeMac('not a mac at all')).toBeNull();
|
|
expect(normalizeMac('aabbcc1122334455')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('findMatchingClient — MAC strategy', () => {
|
|
const clients = [
|
|
{
|
|
id: 'client-name-match',
|
|
description: 'VFI-807-005-168',
|
|
mac: '11:22:33:44:55:66',
|
|
status: 'Offline',
|
|
},
|
|
{
|
|
id: 'client-mac-match',
|
|
description: 'something-totally-different',
|
|
mac: 'AA-BB-CC-DD-EE-FF',
|
|
status: 'Online',
|
|
},
|
|
];
|
|
|
|
it('matches by MAC when present (deterministic, case/separator insensitive)', () => {
|
|
const match = findMatchingClient(clients, { mac: 'aabb.ccdd.eeff' });
|
|
expect(match?.id).toBe('client-mac-match');
|
|
});
|
|
|
|
it('lets MAC win even when a name strategy could also match', () => {
|
|
// The first client has a description that would match by name, but we
|
|
// pass a MAC for the second one — MAC should take priority.
|
|
const match = findMatchingClient(clients, {
|
|
mac: 'aabbccddeeff',
|
|
name: 'VFI-807-005-168',
|
|
});
|
|
expect(match?.id).toBe('client-mac-match');
|
|
});
|
|
|
|
it('falls back to name strategy when MAC is missing or unmatched', () => {
|
|
const match = findMatchingClient(clients, {
|
|
mac: 'ffffffffffff',
|
|
name: 'VFI-807-005-168',
|
|
});
|
|
expect(match?.id).toBe('client-name-match');
|
|
});
|
|
|
|
it('returns null when nothing matches and MAC is invalid', () => {
|
|
const match = findMatchingClient(clients, { mac: 'not-a-mac' });
|
|
expect(match).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('extractHostname', () => {
|
|
it('returns the lowercase short hostname from an FQDN', () => {
|
|
expect(extractHostname('VFI-807-005-168.us000782.stores.ae.com')).toBe('vfi-807-005-168');
|
|
});
|
|
|
|
it('passes a bare IP through (no dot-split semantics for our matching)', () => {
|
|
// Bare IPs still split on the first dot — that's fine because the
|
|
// matcher only uses this as a "starts-with" hint anyway.
|
|
expect(extractHostname('192.168.10.45')).toBe('192');
|
|
});
|
|
|
|
it('returns null for falsy input', () => {
|
|
expect(extractHostname(null)).toBeNull();
|
|
expect(extractHostname(undefined)).toBeNull();
|
|
expect(extractHostname('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getClientStatus', () => {
|
|
it('returns correct emojis and fallback', () => {
|
|
expect(getClientStatus({ status: 'Online' })).toBe('✅ Online');
|
|
expect(getClientStatus({ status: 'Offline' })).toBe('❌ Offline');
|
|
expect(getClientStatus(null)).toBe('❓ Unknown');
|
|
expect(getClientStatus(undefined)).toBe('❓ Unknown');
|
|
});
|
|
});
|
|
|
|
describe('formatLastSeen', () => {
|
|
it('handles missing value', () => {
|
|
expect(formatLastSeen(null)).toBe('N/A');
|
|
expect(formatLastSeen(undefined)).toBe('N/A');
|
|
});
|
|
|
|
it('formats recent times', () => {
|
|
const justNow = new Date();
|
|
expect(formatLastSeen(justNow)).toBe('Just now');
|
|
|
|
const tenMin = new Date(Date.now() - 10 * 60 * 1000);
|
|
expect(formatLastSeen(tenMin)).toMatch(/10 min ago/);
|
|
});
|
|
});
|
|
|
|
describe('buildMerakiClientLink', () => {
|
|
const network = { id: 'N_123', name: 'Store 305', url: 'https://example.com/n/ABC123' };
|
|
|
|
it('builds a client link when possible', () => {
|
|
const client = { id: 'c99' };
|
|
const link = buildMerakiClientLink(network, client);
|
|
expect(link).toContain('/manage/clients/c99/overview');
|
|
});
|
|
|
|
it('returns empty string on bad input', () => {
|
|
expect(buildMerakiClientLink(null, {})).toBe('');
|
|
expect(buildMerakiClientLink(network, null)).toBe('');
|
|
});
|
|
});
|
|
});
|