netanalyzer/tests/store.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

206 lines
6.7 KiB
JavaScript

const { createStore, Store, extractBrands } = require('../models/Store');
describe('Store model', () => {
it('uses store_number from data when no explicit number given', () => {
const s = createStore({ store_number: '305', name: 'Foo' });
expect(s.number).toBe('305');
expect(s.name).toBe('Foo');
});
it('falls back to `Store <n>` when name is missing', () => {
const s = createStore({}, 42);
expect(s.name).toBe('Store 42');
});
it('handles missing address fields without throwing', () => {
const s = createStore({}, 1);
expect(() => s.toSummary()).not.toThrow();
const summary = s.toSummary();
expect(summary).toContain('Store 1');
expect(summary).toContain('District ID: N/A');
});
it('handles null location data without throwing (regression: store 2477)', () => {
const s = createStore(null, 2477);
expect(s.hasLocationData).toBe(false);
expect(s.name).toBe('Store 2477');
const summary = s.toSummary();
expect(summary).toContain('Store 2477');
expect(summary).toContain('No SIW record found');
});
it('handles undefined location data without throwing', () => {
const s = createStore(undefined, 305);
expect(s.hasLocationData).toBe(false);
expect(() => s.toSummary()).not.toThrow();
});
it('builds a full address from optional parts', () => {
const s = new Store(
{
address: '123 Main',
address2: 'Suite 5',
city: 'Anytown',
state: 'CA',
postal_code: '90210',
phone: '555-1234',
},
'305'
);
const addr = s.fullAddress;
expect(addr).toContain('123 Main');
expect(addr).toContain('Suite 5');
expect(addr).toContain('Anytown, CA 90210');
expect(addr).toContain('Phone: 555-1234');
});
it('summary ends with a blank line so the section splitter works', () => {
const s = createStore({ name: 'Foo' }, '305');
// The Meraki section starts with `\n\n**🌐...`. Combined with toSummary's
// trailing newline this gives a clean \n\n** boundary.
expect(s.toSummary()).toMatch(/\n$/);
});
});
describe('extractBrands', () => {
it('returns [] for null / missing data', () => {
expect(extractBrands(null)).toEqual([]);
expect(extractBrands({})).toEqual([]);
});
it('reads an array of plain strings', () => {
expect(extractBrands({ brands: ['AEO', 'Aerie', 'Offline'] })).toEqual([
'AEO',
'Aerie',
'Offline',
]);
});
it('reads an array of objects with brand_display_name', () => {
expect(
extractBrands({
brands: [{ brand_display_name: 'AEO' }, { brand_name: 'Aerie' }, { name: 'Offline' }],
})
).toEqual(['AEO', 'Aerie', 'Offline']);
});
it('reads numbered brand_1..brand_3 fields', () => {
expect(extractBrands({ brand_1: 'AEO', brand_2: 'Aerie', brand_3: 'Offline' })).toEqual([
'AEO',
'Aerie',
'Offline',
]);
});
it('reads brand_display_name_1..3 fields', () => {
expect(
extractBrands({
brand_display_name_1: 'AEO',
brand_display_name_2: 'Aerie',
})
).toEqual(['AEO', 'Aerie']);
});
it('falls back to a single brand field', () => {
expect(extractBrands({ brand_display_name: 'AEO' })).toEqual(['AEO']);
expect(extractBrands({ brand: 'AEO' })).toEqual(['AEO']);
});
it('deduplicates case-insensitively and caps at 3', () => {
expect(
extractBrands({ brands: ['AEO', 'aeo', 'Aerie', 'Offline', 'Todd Snyder', 'Unsubsidiary'] })
).toEqual(['AEO', 'Aerie', 'Offline']);
});
it('Store.brands is populated and rendered in the summary', () => {
const s = createStore(
{ name: 'Mall Store', brand_1: 'AEO', brand_2: 'Aerie', address: '1 Main' },
'2477'
);
expect(s.brands).toEqual(['AEO', 'Aerie']);
const summary = s.toSummary();
expect(summary).toContain('Brands: AEO, Aerie');
});
it('uses singular "Brand:" label when only one brand is present', () => {
const s = createStore({ name: 'X', brand: 'AEO' }, '1');
expect(s.toSummary()).toContain('Brand: AEO');
});
it('reads brands from the real SIW /StoreGeneral shape (incl. `pimary` typo)', () => {
const general = {
pimary_brand_name: 'American Eagle Outfitters',
secondary_brand_name: 'Aerie',
tertiary_brand_name: 'Offline',
};
expect(extractBrands(general)).toEqual(['American Eagle Outfitters', 'Aerie', 'Offline']);
});
it('also reads brands from the spelled-correctly `primary_brand_name` form', () => {
const general = {
primary_brand_name: 'AEO',
secondary_brand_name: 'Aerie',
};
expect(extractBrands(general)).toEqual(['AEO', 'Aerie']);
});
it('dedupes when primary and tertiary are the same brand (real store 782 case)', () => {
const general = {
pimary_brand_name: 'American Eagle Outfitters',
secondary_brand_name: null,
tertiary_brand_name: 'American Eagle Outfitters',
};
expect(extractBrands(general)).toEqual(['American Eagle Outfitters']);
});
});
describe('Store with /StoreGeneral data', () => {
const location = {
name: 'AEO #782',
address: '1 Mall Way',
city: 'Pittsburgh',
state: 'PA',
postal_code: '15222',
};
const general = {
store_number: 782,
brand_code: 'AE',
pimary_brand_name: 'American Eagle Outfitters',
secondary_brand_name: null,
tertiary_brand_name: 'American Eagle Outfitters',
environment_name: 'Prd',
store_status_name: 'Live',
};
it('exposes brands, status, and environment from the general payload', () => {
const s = createStore(location, 782, { general });
expect(s.brands).toEqual(['American Eagle Outfitters']);
expect(s.status).toBe('Live');
expect(s.environment).toBe('Prd');
expect(s.hasGeneralData).toBe(true);
});
it('renders Status and Environment on a single line in the summary', () => {
const s = createStore(location, 782, { general });
const summary = s.toSummary();
expect(summary).toContain('Brand: American Eagle Outfitters');
expect(summary).toMatch(/Status: Live\s+\|\s+Environment: Prd/);
});
it('skips the status/env line when both are missing', () => {
const s = createStore(location, 782, { general: {} });
const summary = s.toSummary();
expect(summary).not.toContain('Status:');
expect(summary).not.toContain('Environment:');
});
it('still renders when only general data is available (no location)', () => {
const s = createStore(null, 782, { general });
expect(s.hasLocationData).toBe(false);
expect(s.hasGeneralData).toBe(true);
const summary = s.toSummary();
expect(summary).toContain('Store 782');
expect(summary).toContain('Brand: American Eagle Outfitters');
expect(summary).toContain('Status: Live');
});
});