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>
206 lines
6.7 KiB
JavaScript
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');
|
|
});
|
|
});
|