From 63f35027f67967ebc0f08ca43ae7dd77cfd75e2d Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Mon, 27 Jul 2026 14:57:45 -0400 Subject: [PATCH] Add daily store phone export to Webex space Weekday 10am Eastern cron exports all Webex Calling locations whose name starts with "Store" to a legacy-format CSV (+E164,Store XXXX), diffs against the previous baseline, and posts the file plus a change summary to a configured Webex space. - src/webex/storePhones.js: paginated GET /telephony/config/locations - src/services/storePhoneExport.js: CSV format, filename, diff, message - src/webex/messages.js: bot-token multipart POST /messages with file - src/jobs/storePhoneExport.js + cron in src/index.js - scripts/exportStorePhones.js for manual runs (npm run export-store-phones) - Persist baseline + archives under data/store-phone-export/ (gitignored) - Dockerfile: create /app/data for volume mount in prod - CI: align node-version with Dockerfile (20) Co-authored-by: Cursor --- .env.example | 7 ++ .gitea/workflows/ci.yml | 6 +- .gitignore | 3 + Dockerfile | 8 +- README.md | 49 ++++++++- package.json | 1 + scripts/exportStorePhones.js | 14 +++ src/config.js | 6 ++ src/index.js | 13 +++ src/jobs/storePhoneExport.js | 84 +++++++++++++++ src/services/storePhoneExport.js | 169 +++++++++++++++++++++++++++++++ src/webex/messages.js | 44 ++++++++ src/webex/storePhones.js | 41 ++++++++ test/storePhoneExport.test.js | 135 ++++++++++++++++++++++++ 14 files changed, 573 insertions(+), 7 deletions(-) create mode 100644 scripts/exportStorePhones.js create mode 100644 src/jobs/storePhoneExport.js create mode 100644 src/services/storePhoneExport.js create mode 100644 src/webex/messages.js create mode 100644 src/webex/storePhones.js create mode 100644 test/storePhoneExport.test.js diff --git a/.env.example b/.env.example index 42d79c5..ef67a6f 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,10 @@ GOOGLE_API_KEY=your-google-api-key # --- Runtime --- NODE_ENV=production LOG_LEVEL=info + +# --- Daily store phone export (weekdays 10:00 AM Eastern) --- +# Webex room/space ID to post the CSV into. The bot must be a member of +# this space. Leave unset to disable the scheduled export. +# STORE_PHONE_EXPORT_ROOM_ID=your-webex-room-id +# STORE_PHONE_EXPORT_TIMEZONE=America/New_York +# STORE_PHONE_EXPORT_DATA_DIR=./data/store-phone-export diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7567559..9fa347c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,7 +1,7 @@ # Gitea Actions CI for wbxcallprov. # # Runs on every push and PR against main. Matches the runtime image: -# node:22-alpine in Dockerfile -> node-version: 22 here. +# node:20-alpine in Dockerfile -> node-version: 20 here. # # The three checks are the same ones a developer runs locally before pushing: # npm run lint - ESLint flat config, catches unused vars / var / == @@ -25,10 +25,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up Node 22 + - name: Set up Node 20 uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '20' cache: 'npm' - name: Install dependencies diff --git a/.gitignore b/.gitignore index 8cac650..2ee59a3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ buildlogs.log buildingFile.csv locationFile.csv +# Daily store phone export baseline + archives +data/ + # Archives *.zip diff --git a/Dockerfile b/Dockerfile index fd75802..6b08ec9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ # syntax=docker/dockerfile:1.7 # --- build stage: install production dependencies only --- -FROM node:22-alpine AS deps +FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev --no-audit --no-fund # --- runtime stage: minimal image with just what's needed to run --- -FROM node:22-alpine AS runtime +FROM node:20-alpine AS runtime ENV NODE_ENV=production WORKDIR /app @@ -22,6 +22,10 @@ COPY --chown=node:node greetings ./greetings # Directory the token-refresh cron writes to. Mount a volume here in prod. RUN mkdir -p /app/config && chown node:node /app/config +# Daily store phone export baseline + archives. Mount a volume at /app/data +# in prod so diffs survive container restarts. +RUN mkdir -p /app/data && chown node:node /app/data + # WebSocket port the on-prem SIW agent connects back to. Override at runtime # via -e WS_PORT and -p host:container. EXPOSE 8080 diff --git a/README.md b/README.md index 5fa9cff..c77487e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ greetings, build auto-attendants, and clean up user licensing. ## Requirements -- Node.js 20+ (Docker image uses `node:22-alpine`). +- Node.js 20+ (Docker image uses `node:20-alpine`; Node 22 breaks + `webex-node-bot-framework` because its `navigator` global is read-only). - A Webex bot token, a Webex integration (service account) with the scopes currently used by admin API calls, a Twilio lookup account, an SIW basic-auth user, and a Google API key with Address Validation + Time Zone enabled. @@ -41,6 +42,22 @@ date from then on. ## Docker +### Docker Compose (recommended) + +```bash +cp .env.example .env +# Edit .env with real values and seed config/wbxTokens.json (see above) + +docker compose up -d --build +docker compose logs -f +``` + +`docker-compose.yml` builds the image, loads `.env`, mounts `./config` for +token persistence, and publishes `WS_PORT` (default `8080`) so the remote SIW +agent can connect back to the bot. + +### Plain Docker + ```bash docker build -t wbxcallprov . docker run --rm --env-file .env \ @@ -167,6 +184,33 @@ in Control Hub → `/finalizeStore 499`. Card confirmations post `attachmentAction` events, dispatched in [src/commands/attachmentActions.js](src/commands/attachmentActions.js). +## Scheduled jobs + +The bot runs two background tasks via `node-cron` in [src/index.js](src/index.js): + +- **Token refresh** — every 60 seconds, refreshes the Webex service-account + access token when it is within 2 hours of expiry. +- **Store phone export** — weekdays at 10:00 AM `America/New_York` (configurable + via `STORE_PHONE_EXPORT_TIMEZONE`). Exports all Webex Calling locations whose + name starts with `Store` to a CSV (`+E164,Store XXXX` per line), diffs against + the previous run, and posts the file plus a change summary to a Webex space. + +Configure the export in `.env`: + +- `STORE_PHONE_EXPORT_ROOM_ID` — target space ID (required for the job to run). + The bot must be a member of this space. +- `STORE_PHONE_EXPORT_TIMEZONE` — cron timezone (default `America/New_York`). +- `STORE_PHONE_EXPORT_DATA_DIR` — where baseline + archive CSVs are stored + (default `./data/store-phone-export`). Mount `/app/data` as a volume in Docker + so diffs survive restarts. + +Manual trigger (same code path as the cron): + +```bash +npm run export-store-phones +# or: node scripts/exportStorePhones.js +``` + ## Architecture ``` @@ -181,7 +225,8 @@ src/ cards/ Adaptive Card builders flows/ multi-step provisioning (stage, finalize) commands/ framework.hears handlers + attachmentAction dispatch -scripts/ one-off maintenance scripts (911 CSV, phone fix-up) + jobs/ scheduled background tasks (store phone export) +scripts/ one-off maintenance scripts (911 CSV, phone fix-up, export) greetings/ WAV files uploaded as location announcements config/wbxTokens.json persistent service-account token cache (git-ignored) ``` diff --git a/package.json b/package.json index 8297bd5..b6ce945 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "format:check": "prettier --check .", "generate-911-csv": "node scripts/generate911Csv.js", "fix-store-phones": "node scripts/fixStorePhones.js", + "export-store-phones": "node scripts/exportStorePhones.js", "agent:package": "./docker/remote-agent/package.sh", "agent:check": "./docker/remote-agent/package.sh --check", "agent:inspect": "./docker/remote-agent/inspect-bundle.sh" diff --git a/scripts/exportStorePhones.js b/scripts/exportStorePhones.js new file mode 100644 index 0000000..11b5049 --- /dev/null +++ b/scripts/exportStorePhones.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node +/** + * Manual trigger for the daily store phone export job. + * Useful for testing without waiting for the weekday 10am cron. + * + * Usage: + * node scripts/exportStorePhones.js + * + * Requires STORE_PHONE_EXPORT_ROOM_ID (and the usual Webex credentials) + * in .env. + */ +import { runStorePhoneExport } from '../src/jobs/storePhoneExport.js'; + +await runStorePhoneExport(); diff --git a/src/config.js b/src/config.js index 5961c5e..a22441d 100644 --- a/src/config.js +++ b/src/config.js @@ -74,6 +74,12 @@ export const config = { google: { apiKey: required('GOOGLE_API_KEY'), }, + + storePhoneExport: { + roomId: optional('STORE_PHONE_EXPORT_ROOM_ID', ''), + timeZone: optional('STORE_PHONE_EXPORT_TIMEZONE', 'America/New_York'), + dataDir: resolvePath(optional('STORE_PHONE_EXPORT_DATA_DIR', './data/store-phone-export')), + }, }; export default config; diff --git a/src/index.js b/src/index.js index 1a06a57..1655846 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import { register as registerProvisionPhone } from './commands/provisionPhone.js import { register as registerStoreInfo } from './commands/storeInfo.js'; import { register as registerUserInfo } from './commands/userInfo.js'; import { register as registerAttachmentActions } from './commands/attachmentActions.js'; +import { runStorePhoneExport } from './jobs/storePhoneExport.js'; const framework = new Framework(config.webexBot); @@ -68,6 +69,18 @@ cron.schedule('0 * * * * *', async () => { } }); +// Weekdays at 10:00 AM Eastern: export Store phone numbers to CSV and +// post to the configured Webex space with a change summary. +cron.schedule( + '0 10 * * 1-5', + () => { + runStorePhoneExport().catch((error) => { + logger.error('Scheduled store phone export failed:', error); + }); + }, + { timezone: config.storePhoneExport.timeZone }, +); + framework .start() .then(() => { diff --git a/src/jobs/storePhoneExport.js b/src/jobs/storePhoneExport.js new file mode 100644 index 0000000..e7323db --- /dev/null +++ b/src/jobs/storePhoneExport.js @@ -0,0 +1,84 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { config } from '../config.js'; +import { logger } from '../logger.js'; +import { listStorePhoneRows } from '../webex/storePhones.js'; +import { postRoomMessage } from '../webex/messages.js'; +import { + diffStorePhones, + formatExportFilename, + formatExportMessage, + parseStorePhoneCsv, + rowsToCsv, +} from '../services/storePhoneExport.js'; + +const BASELINE_NAME = 'latest.csv'; + +/** + * Daily store-phone export: fetch Store locations from Webex Calling, + * diff against the previous baseline, write archive + latest CSV, and + * post the file with a change summary to the configured Webex space. + * + * Safe to call from cron or a manual script — errors are logged and + * do not propagate to the caller (so a failed export never takes + * down the bot). + */ +export async function runStorePhoneExport() { + const { roomId, dataDir, timeZone } = config.storePhoneExport; + + if (!roomId) { + logger.info('Store phone export skipped: STORE_PHONE_EXPORT_ROOM_ID is not configured.'); + return; + } + + const now = new Date(); + logger.info('Starting daily store phone export...'); + + try { + await fs.mkdir(dataDir, { recursive: true }); + + const rows = await listStorePhoneRows(); + const csv = rowsToCsv(rows); + const currentMap = parseStorePhoneCsv(csv); + + const baselinePath = path.join(dataDir, BASELINE_NAME); + let previousMap = new Map(); + let isFirstRun = false; + + try { + const baselineText = await fs.readFile(baselinePath, 'utf8'); + previousMap = parseStorePhoneCsv(baselineText); + } catch (err) { + if (err.code === 'ENOENT') { + isFirstRun = true; + } else { + throw err; + } + } + + const diff = diffStorePhones(previousMap, currentMap); + const filename = formatExportFilename(now, timeZone); + const archivePath = path.join(dataDir, filename); + + await fs.writeFile(archivePath, csv, 'utf8'); + + const markdown = formatExportMessage({ + date: now, + timeZone, + rowCount: rows.length, + diff, + isFirstRun, + }); + + await postRoomMessage(roomId, markdown, [{ filename, content: csv, mimeType: 'text/csv' }]); + + await fs.writeFile(baselinePath, csv, 'utf8'); + + logger.info( + `Store phone export complete: ${rows.length} rows, file ${filename}, posted to room ${roomId}`, + ); + } catch (error) { + logger.error('Store phone export failed:', error); + } +} diff --git a/src/services/storePhoneExport.js b/src/services/storePhoneExport.js new file mode 100644 index 0000000..1bacb89 --- /dev/null +++ b/src/services/storePhoneExport.js @@ -0,0 +1,169 @@ +// Pure helpers for the daily StorePhone CSV export: formatting, +// parsing, diffing, and filename generation. No I/O — easy to test. + +/** + * Serialize rows to the legacy StorePhone CSV format (no header): + * +14044708802,Store 7311 + */ +export function rowsToCsv(rows) { + return rows.map((r) => `${r.phoneNumber},${r.name}`).join('\n') + (rows.length ? '\n' : ''); +} + +/** + * Parse a StorePhone CSV back into a Map. + * Ignores blank lines and malformed rows. + */ +export function parseStorePhoneCsv(text) { + const map = new Map(); + for (const line of String(text ?? '').split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const comma = trimmed.indexOf(','); + if (comma < 1) continue; + const phoneNumber = trimmed.slice(0, comma); + const name = trimmed.slice(comma + 1); + if (name) map.set(name, phoneNumber); + } + return map; +} + +/** + * Build a filename matching the legacy convention: + * StorePhone-2026-07-15T09_10_03.455-04_00.csv + * Colons in the time and offset are replaced with underscores. + */ +export function formatExportFilename(date, timeZone = 'America/New_York') { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + hour12: false, + timeZoneName: 'longOffset', + }).formatToParts(date); + + const get = (type) => parts.find((p) => p.type === type)?.value ?? ''; + const y = get('year'); + const mo = get('month'); + const d = get('day'); + const h = get('hour'); + const mi = get('minute'); + const s = get('second'); + const frac = get('fractionalSecond') || '000'; + + // longOffset comes back as "GMT-04:00" or "GMT+05:30" — strip prefix, + // keep sign, replace colons with underscores. + let offset = get('timeZoneName').replace(/^GMT/, ''); + if (!offset || offset === 'GMT') { + offset = formatOffsetFallback(date, timeZone); + } + offset = offset.replace(/:/g, '_'); + + return `StorePhone-${y}-${mo}-${d}T${h}_${mi}_${s}.${frac}${offset}.csv`; +} + +/** Fallback when formatToParts does not return a usable offset string. */ +function formatOffsetFallback(date, timeZone) { + try { + const short = new Intl.DateTimeFormat('en-US', { + timeZone, + timeZoneName: 'shortOffset', + }) + .formatToParts(date) + .find((p) => p.type === 'timeZoneName')?.value; + if (short && short !== 'GMT') { + return short.replace(/^GMT/, ''); + } + } catch { + // ignore + } + return '+00_00'; +} + +/** + * Diff two phone maps (key = full location name). Returns lists of + * added/removed/changed entries plus a count of unchanged rows. + */ +export function diffStorePhones(previous, current) { + const added = []; + const removed = []; + const changed = []; + let unchangedCount = 0; + + for (const [name, phoneNumber] of current) { + if (!previous.has(name)) { + added.push({ name, phoneNumber }); + } else if (previous.get(name) !== phoneNumber) { + changed.push({ name, oldPhone: previous.get(name), newPhone: phoneNumber }); + } else { + unchangedCount++; + } + } + + for (const [name, phoneNumber] of previous) { + if (!current.has(name)) { + removed.push({ name, phoneNumber }); + } + } + + added.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + removed.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + changed.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + + return { added, removed, changed, unchangedCount }; +} + +/** + * Build the markdown body posted above the CSV attachment. + */ +export function formatExportMessage({ date, timeZone, rowCount, diff, isFirstRun }) { + const dateLabel = new Intl.DateTimeFormat('en-US', { + timeZone, + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(date); + + const lines = [ + `**Daily Store Phone Export** — ${dateLabel}`, + '', + `**${rowCount.toLocaleString()}** store${rowCount === 1 ? '' : 's'} exported.`, + '', + ]; + + if (isFirstRun) { + lines.push('_First export — no prior baseline to compare._'); + return lines.join('\n'); + } + + const { added, removed, changed } = diff; + const hasChanges = added.length > 0 || removed.length > 0 || changed.length > 0; + + if (!hasChanges) { + lines.push('_No changes since last run._'); + return lines.join('\n'); + } + + lines.push('**Changes since last run:**'); + + if (added.length > 0) { + const items = added.map((r) => `${r.name} (${r.phoneNumber})`).join(', '); + lines.push(`- **Added (${added.length}):** ${items}`); + } + if (removed.length > 0) { + const items = removed.map((r) => `${r.name} (${r.phoneNumber})`).join(', '); + lines.push(`- **Removed (${removed.length}):** ${items}`); + } + if (changed.length > 0) { + const items = changed.map((r) => `${r.name}: ${r.oldPhone} → ${r.newPhone}`).join(', '); + lines.push(`- **Changed (${changed.length}):** ${items}`); + } + + lines.push('', '_No other changes._'); + return lines.join('\n'); +} diff --git a/src/webex/messages.js b/src/webex/messages.js new file mode 100644 index 0000000..8693e18 --- /dev/null +++ b/src/webex/messages.js @@ -0,0 +1,44 @@ +// Post messages (with optional file attachments) to a Webex room using +// the bot token. Admin telephony reads use the service account; room +// messages require the bot because it is the space member. + +import { fetchWithRateLimit } from '../http.js'; +import { WEBEX_API_BASE } from '../constants.js'; +import { config } from '../config.js'; + +/** + * Post a markdown message to a room, optionally attaching one or more + * files. `files` is an array of { filename, content, mimeType? }. + */ +export async function postRoomMessage(roomId, markdown, files = []) { + if (!roomId) throw new Error('roomId required'); + const form = new FormData(); + form.append('roomId', roomId); + if (markdown) form.append('markdown', markdown); + + for (const file of files) { + const mime = file.mimeType ?? 'text/csv'; + form.append('files', new Blob([file.content], { type: mime }), file.filename); + } + + const response = await fetchWithRateLimit(`${WEBEX_API_BASE}/messages`, { + method: 'POST', + headers: { + Authorization: `Bearer ${config.webexBot.token}`, + }, + body: form, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error( + `Failed to post message to room ${roomId}: HTTP ${response.status} ${response.statusText}${errorText ? `\n${errorText}` : ''}`, + ); + } + + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + return response.json(); + } + return undefined; +} diff --git a/src/webex/storePhones.js b/src/webex/storePhones.js new file mode 100644 index 0000000..63b3210 --- /dev/null +++ b/src/webex/storePhones.js @@ -0,0 +1,41 @@ +// Store phone export helpers for the daily StorePhone CSV job. +// +// Pulls calling-line numbers from the org-wide telephony locations list +// (one paginated sweep) rather than per-location GETs. + +import { webexListAll } from './client.js'; +import { logger } from '../logger.js'; + +/** + * List all Store locations with an assigned main calling-line number. + * Returns rows sorted by location name for stable CSV output and diffs. + * Locations whose name does not start with "Store" or that lack a + * callingLineId.phoneNumber are skipped (the latter is logged). + */ +export async function listStorePhoneRows() { + const locations = await webexListAll('/telephony/config/locations', { + itemsKey: 'locations', + }); + + const storeLocations = locations.filter((loc) => loc.name?.startsWith('Store')); + const withPhone = []; + let skippedNoPhone = 0; + + for (const loc of storeLocations) { + const phoneNumber = loc.callingLineId?.phoneNumber; + if (!phoneNumber) { + skippedNoPhone++; + continue; + } + withPhone.push({ phoneNumber, name: loc.name }); + } + + if (skippedNoPhone > 0) { + logger.info( + `listStorePhoneRows: skipped ${skippedNoPhone} Store location(s) with no calling line number`, + ); + } + + withPhone.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + return withPhone; +} diff --git a/test/storePhoneExport.test.js b/test/storePhoneExport.test.js new file mode 100644 index 0000000..7a337ab --- /dev/null +++ b/test/storePhoneExport.test.js @@ -0,0 +1,135 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + diffStorePhones, + formatExportFilename, + formatExportMessage, + parseStorePhoneCsv, + rowsToCsv, +} from '../src/services/storePhoneExport.js'; + +describe('rowsToCsv / parseStorePhoneCsv', () => { + it('round-trips rows with no header', () => { + const rows = [ + { phoneNumber: '+14044708802', name: 'Store 7311' }, + { phoneNumber: '+17245537090', name: 'Store 0006 - TestLab' }, + ]; + const csv = rowsToCsv(rows); + assert.equal(csv, '+14044708802,Store 7311\n+17245537090,Store 0006 - TestLab\n'); + const map = parseStorePhoneCsv(csv); + assert.equal(map.size, 2); + assert.equal(map.get('Store 7311'), '+14044708802'); + assert.equal(map.get('Store 0006 - TestLab'), '+17245537090'); + }); + + it('returns empty string for no rows', () => { + assert.equal(rowsToCsv([]), ''); + assert.equal(parseStorePhoneCsv('').size, 0); + }); + + it('ignores blank and malformed lines when parsing', () => { + const text = '+15551234567,Store 0499\n\nbadline\n,+1\n'; + const map = parseStorePhoneCsv(text); + assert.equal(map.size, 1); + assert.equal(map.get('Store 0499'), '+15551234567'); + }); +}); + +describe('formatExportFilename', () => { + it('matches StorePhone-YYYY-MM-DDT…csv with underscores in time and offset', () => { + // Jul 15 2026 09:10:03.455 EDT = UTC-4 + const date = new Date('2026-07-15T13:10:03.455Z'); + const name = formatExportFilename(date, 'America/New_York'); + assert.match( + name, + /^StorePhone-\d{4}-\d{2}-\d{2}T\d{2}_\d{2}_\d{2}\.\d{3}[+-]\d{2}_\d{2}\.csv$/, + ); + assert.ok(name.includes('2026-07-15'), name); + assert.ok(name.includes('09_10_03'), name); + }); +}); + +describe('diffStorePhones', () => { + const prev = new Map([ + ['Store 0499', '+15551111111'], + ['Store 0500', '+15552222222'], + ['Store 0999', '+15559999999'], + ]); + + it('detects added, removed, and changed rows', () => { + const curr = new Map([ + ['Store 0499', '+15559876543'], + ['Store 0500', '+15552222222'], + ['Store 7311', '+14044708802'], + ]); + const diff = diffStorePhones(prev, curr); + assert.equal(diff.added.length, 1); + assert.equal(diff.added[0].name, 'Store 7311'); + assert.equal(diff.removed.length, 1); + assert.equal(diff.removed[0].name, 'Store 0999'); + assert.equal(diff.changed.length, 1); + assert.equal(diff.changed[0].name, 'Store 0499'); + assert.equal(diff.unchangedCount, 1); + }); + + it('reports no changes when maps are identical', () => { + const diff = diffStorePhones(prev, new Map(prev)); + assert.equal(diff.added.length, 0); + assert.equal(diff.removed.length, 0); + assert.equal(diff.changed.length, 0); + assert.equal(diff.unchangedCount, 3); + }); +}); + +describe('formatExportMessage', () => { + const date = new Date('2026-07-15T14:00:00.000Z'); + + it('notes first run when there is no baseline', () => { + const msg = formatExportMessage({ + date, + timeZone: 'America/New_York', + rowCount: 1081, + diff: { added: [], removed: [], changed: [], unchangedCount: 0 }, + isFirstRun: true, + }); + assert.match(msg, /First export/); + assert.match(msg, /1,081/); + }); + + it('lists changes when present', () => { + const msg = formatExportMessage({ + date, + timeZone: 'America/New_York', + rowCount: 3, + isFirstRun: false, + diff: { + added: [{ name: 'Store 7311', phoneNumber: '+14044708802' }], + removed: [], + changed: [ + { + name: 'Store 0499', + oldPhone: '+15551111111', + newPhone: '+15559876543', + }, + ], + unchangedCount: 1, + }, + }); + assert.match(msg, /Added \(1\)/); + assert.match(msg, /Store 7311/); + assert.match(msg, /Changed \(1\)/); + assert.match(msg, /0499/); + }); + + it('says no changes when diff is empty', () => { + const msg = formatExportMessage({ + date, + timeZone: 'America/New_York', + rowCount: 10, + isFirstRun: false, + diff: { added: [], removed: [], changed: [], unchangedCount: 10 }, + }); + assert.match(msg, /No changes since last run/); + }); +});