Add daily store phone export to Webex space
Some checks failed
CI / verify (push) Has been cancelled

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 <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-27 14:57:45 -04:00
parent 3daf2fcdc5
commit 63f35027f6
14 changed files with 573 additions and 7 deletions

View file

@ -43,3 +43,10 @@ GOOGLE_API_KEY=your-google-api-key
# --- Runtime --- # --- Runtime ---
NODE_ENV=production NODE_ENV=production
LOG_LEVEL=info 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

View file

@ -1,7 +1,7 @@
# Gitea Actions CI for wbxcallprov. # Gitea Actions CI for wbxcallprov.
# #
# Runs on every push and PR against main. Matches the runtime image: # 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: # The three checks are the same ones a developer runs locally before pushing:
# npm run lint - ESLint flat config, catches unused vars / var / == # npm run lint - ESLint flat config, catches unused vars / var / ==
@ -25,10 +25,10 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node 22 - name: Set up Node 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '20'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies

3
.gitignore vendored
View file

@ -24,6 +24,9 @@ buildlogs.log
buildingFile.csv buildingFile.csv
locationFile.csv locationFile.csv
# Daily store phone export baseline + archives
data/
# Archives # Archives
*.zip *.zip

View file

@ -1,13 +1,13 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# --- build stage: install production dependencies only --- # --- build stage: install production dependencies only ---
FROM node:22-alpine AS deps FROM node:20-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund RUN npm ci --omit=dev --no-audit --no-fund
# --- runtime stage: minimal image with just what's needed to run --- # --- 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 ENV NODE_ENV=production
WORKDIR /app 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. # Directory the token-refresh cron writes to. Mount a volume here in prod.
RUN mkdir -p /app/config && chown node:node /app/config 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 # WebSocket port the on-prem SIW agent connects back to. Override at runtime
# via -e WS_PORT and -p host:container. # via -e WS_PORT and -p host:container.
EXPOSE 8080 EXPOSE 8080

View file

@ -6,7 +6,8 @@ greetings, build auto-attendants, and clean up user licensing.
## Requirements ## 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 - 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 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. user, and a Google API key with Address Validation + Time Zone enabled.
@ -41,6 +42,22 @@ date from then on.
## Docker ## 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 ```bash
docker build -t wbxcallprov . docker build -t wbxcallprov .
docker run --rm --env-file .env \ docker run --rm --env-file .env \
@ -167,6 +184,33 @@ in Control Hub → `/finalizeStore 499`.
Card confirmations post `attachmentAction` events, dispatched in Card confirmations post `attachmentAction` events, dispatched in
[src/commands/attachmentActions.js](src/commands/attachmentActions.js). [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 ## Architecture
``` ```
@ -181,7 +225,8 @@ src/
cards/ Adaptive Card builders cards/ Adaptive Card builders
flows/ multi-step provisioning (stage, finalize) flows/ multi-step provisioning (stage, finalize)
commands/ framework.hears handlers + attachmentAction dispatch 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 greetings/ WAV files uploaded as location announcements
config/wbxTokens.json persistent service-account token cache (git-ignored) config/wbxTokens.json persistent service-account token cache (git-ignored)
``` ```

View file

@ -20,6 +20,7 @@
"format:check": "prettier --check .", "format:check": "prettier --check .",
"generate-911-csv": "node scripts/generate911Csv.js", "generate-911-csv": "node scripts/generate911Csv.js",
"fix-store-phones": "node scripts/fixStorePhones.js", "fix-store-phones": "node scripts/fixStorePhones.js",
"export-store-phones": "node scripts/exportStorePhones.js",
"agent:package": "./docker/remote-agent/package.sh", "agent:package": "./docker/remote-agent/package.sh",
"agent:check": "./docker/remote-agent/package.sh --check", "agent:check": "./docker/remote-agent/package.sh --check",
"agent:inspect": "./docker/remote-agent/inspect-bundle.sh" "agent:inspect": "./docker/remote-agent/inspect-bundle.sh"

View file

@ -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();

View file

@ -74,6 +74,12 @@ export const config = {
google: { google: {
apiKey: required('GOOGLE_API_KEY'), 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; export default config;

View file

@ -11,6 +11,7 @@ import { register as registerProvisionPhone } from './commands/provisionPhone.js
import { register as registerStoreInfo } from './commands/storeInfo.js'; import { register as registerStoreInfo } from './commands/storeInfo.js';
import { register as registerUserInfo } from './commands/userInfo.js'; import { register as registerUserInfo } from './commands/userInfo.js';
import { register as registerAttachmentActions } from './commands/attachmentActions.js'; import { register as registerAttachmentActions } from './commands/attachmentActions.js';
import { runStorePhoneExport } from './jobs/storePhoneExport.js';
const framework = new Framework(config.webexBot); 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 framework
.start() .start()
.then(() => { .then(() => {

View file

@ -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);
}
}

View file

@ -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<locationName, phoneNumber>.
* 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');
}

44
src/webex/messages.js Normal file
View file

@ -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;
}

41
src/webex/storePhones.js Normal file
View file

@ -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;
}

View file

@ -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/);
});
});