diff --git a/.dockerignore b/.dockerignore index 4f58e19..d2a6db0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -25,9 +25,11 @@ storeAddress.js # Local scripts/tooling not needed at runtime scripts/ +test/ eslint.config.js .prettierrc.json .prettierignore +.gitea/ # Remote-agent packaging (its own Dockerfile / image; not part of the bot image) docker/ diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7567559 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,44 @@ +# 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. +# +# 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 format:check - Prettier drift detection (writes are separate) +# npm test - node --test suite for pure-logic functions +# +# Tests never hit the network; dummy env vars come from test/setup.js. + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node 22 + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Format check + run: npm run format:check + + - name: Test + run: npm test diff --git a/.prettierignore b/.prettierignore index 9f9b6ad..adc7766 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,7 @@ buildingFile.csv locationFile.csv package-lock.json config/wbxTokens.json + +# Remote agent is a self-contained sub-project with its own package.json +# and its own tooling; it manages its own formatting. +docker/ diff --git a/package.json b/package.json index f63aeed..878938c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "scripts": { "start": "node src/index.js", "dev": "node --watch src/index.js", + "test": "node --test --import ./test/setup.js test/", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", diff --git a/src/webex/client.js b/src/webex/client.js index e3a0f3b..b6e0ba0 100644 --- a/src/webex/client.js +++ b/src/webex/client.js @@ -61,7 +61,7 @@ export async function webexListAll(initialUrl, { itemsKey = 'items' } = {}) { return collected; } -function parseNextLink(linkHeader) { +export function parseNextLink(linkHeader) { if (!linkHeader) return null; for (const part of linkHeader.split(',')) { const match = part.trim().match(/^<([^>]+)>;\s*rel="?next"?/); diff --git a/test/google.test.js b/test/google.test.js new file mode 100644 index 0000000..6d46da7 --- /dev/null +++ b/test/google.test.js @@ -0,0 +1,55 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { formatE911Address, formatSuite } from '../src/integrations/google.js'; + +// Minimal shape of the Google Address Validation response we care about. +function makeResponse(components) { + return { result: { address: { addressComponents: components } } }; +} + +const fullAddress = makeResponse([ + { componentType: 'street_number', componentName: { text: '77' } }, + { componentType: 'route', componentName: { text: 'Willowbrook Rd' } }, + { componentType: 'subpremise', componentName: { text: 'Ste 2091' } }, + { componentType: 'locality', componentName: { text: 'Wayne' } }, + { componentType: 'administrative_area_level_1', componentName: { text: 'NJ' } }, + { componentType: 'postal_code', componentName: { text: '07470' } }, + { componentType: 'country', componentName: { text: 'United States' } }, +]); + +describe('formatE911Address', () => { + it('flattens the address components into an E911-shaped string', () => { + assert.equal(formatE911Address(fullAddress), '77 Willowbrook Rd, Wayne, NJ 07470'); + }); + + it('tolerates missing components without crashing', () => { + const partial = makeResponse([ + { componentType: 'street_number', componentName: { text: '10' } }, + { componentType: 'route', componentName: { text: 'Main St' } }, + { componentType: 'locality', componentName: { text: 'Anywhere' } }, + ]); + // Missing state/zip render as empty slots; the join stays intact. + assert.equal(formatE911Address(partial), '10 Main St, Anywhere,'); + }); + + it('handles an empty component list', () => { + // The template renders three comma-separated sections; with every + // field empty they collapse to just the separators after trim. + assert.equal(formatE911Address(makeResponse([])), ', ,'); + }); +}); + +describe('formatSuite', () => { + it('returns the subpremise text when present', () => { + assert.equal(formatSuite(fullAddress), 'Ste 2091'); + }); + + it('returns empty string when there is no subpremise', () => { + const noSuite = makeResponse([ + { componentType: 'street_number', componentName: { text: '77' } }, + { componentType: 'route', componentName: { text: 'Willowbrook Rd' } }, + ]); + assert.equal(formatSuite(noSuite), ''); + }); +}); diff --git a/test/greetingSelector.test.js b/test/greetingSelector.test.js new file mode 100644 index 0000000..56c2024 --- /dev/null +++ b/test/greetingSelector.test.js @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { greetingForBrand } from '../src/flows/greetingSelector.js'; +import { GREETINGS } from '../src/constants.js'; + +describe('greetingForBrand', () => { + it('returns the correct file+label for a known brand', () => { + const result = greetingForBrand({ brand: 'Aerie', storeNumber: 792 }); + assert.equal(result.file, GREETINGS.Aerie.file); + assert.equal(result.fileName, '792 - Aerie Greeting.wav'); + }); + + it('picks the American Eagle greeting for the AE brand', () => { + const result = greetingForBrand({ + brand: 'American Eagle Outfitters', + storeNumber: 499, + }); + assert.equal(result.file, GREETINGS['American Eagle Outfitters'].file); + assert.equal(result.fileName, '499 - AE Greeting.wav'); + }); + + it('falls back to the AE greeting for unknown brands', () => { + const result = greetingForBrand({ brand: 'MysteryBrand', storeNumber: 42 }); + assert.equal(result.file, GREETINGS['American Eagle Outfitters'].file); + // Filename still reflects the caller's storeNumber and the AE label. + assert.equal(result.fileName, '42 - AE Greeting.wav'); + }); +}); diff --git a/test/helpers.test.js b/test/helpers.test.js new file mode 100644 index 0000000..f4368ce --- /dev/null +++ b/test/helpers.test.js @@ -0,0 +1,74 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseStoreArg, parseStoreNumber, storeEmail } from '../src/commands/helpers.js'; + +// parseStoreArg regressed once already: the framework's trigger.prompt is +// everything AFTER the matched command (e.g. " 792" for "/storeInfo 792"), +// but the old implementation split and took index [1], which returned +// undefined. These tests lock that behavior down. +describe('parseStoreArg', () => { + it('reads first token from trigger.prompt (DM path)', () => { + assert.equal(parseStoreArg({ prompt: ' 792' }), '792'); + }); + + it('handles a prompt with trailing junk', () => { + assert.equal(parseStoreArg({ prompt: ' 499 extra text' }), '499'); + }); + + it('falls back to trigger.args when prompt is empty (DM)', () => { + // In DMs the framework populates args as [command, ...positional]. + assert.equal(parseStoreArg({ prompt: '', args: ['/storeInfo', '792'] }), '792'); + }); + + it('falls back to trigger.args in mentioned group rooms', () => { + // In group rooms args[0] is the bot name; we look for the first + // token starting with '/' and take the one after it. + assert.equal( + parseStoreArg({ prompt: '', args: ['aeoCallProvisioning', '/storeInfo', '792'] }), + '792', + ); + }); + + it('returns undefined when nothing usable is present', () => { + assert.equal(parseStoreArg({}), undefined); + assert.equal(parseStoreArg({ prompt: ' ', args: [] }), undefined); + assert.equal(parseStoreArg(null), undefined); + assert.equal(parseStoreArg(undefined), undefined); + }); +}); + +describe('parseStoreNumber', () => { + it('accepts 1-5 digit numeric strings', () => { + assert.equal(parseStoreNumber({ prompt: ' 1' }), '1'); + assert.equal(parseStoreNumber({ prompt: ' 499' }), '499'); + assert.equal(parseStoreNumber({ prompt: ' 12345' }), '12345'); + }); + + it('rejects non-numeric input', () => { + assert.equal(parseStoreNumber({ prompt: ' abc' }), null); + assert.equal(parseStoreNumber({ prompt: ' 49a' }), null); + assert.equal(parseStoreNumber({ prompt: ' 4-9' }), null); + }); + + it('rejects overly long numbers', () => { + assert.equal(parseStoreNumber({ prompt: ' 123456' }), null); + }); + + it('returns null when the arg is missing', () => { + assert.equal(parseStoreNumber({}), null); + assert.equal(parseStoreNumber({ prompt: '' }), null); + }); +}); + +describe('storeEmail', () => { + it('zero-pads to 5 digits and appends @ae.com', () => { + assert.equal(storeEmail(499), 'ae00499@ae.com'); + assert.equal(storeEmail(1), 'ae00001@ae.com'); + assert.equal(storeEmail(12345), 'ae12345@ae.com'); + }); + + it('accepts numeric-string input', () => { + assert.equal(storeEmail('42'), 'ae00042@ae.com'); + }); +}); diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..76de68c --- /dev/null +++ b/test/setup.js @@ -0,0 +1,27 @@ +// Loaded before every test file via `node --test --import ./test/setup.js`. +// +// Its only job is to satisfy the `required(...)` env checks in src/config.js +// so we can `import` any module in the tree from a test file without +// module-load blowing up. None of these values are ever used at rest — the +// tests exercise pure functions, and any code path that would actually make +// a network call is either mocked or not exercised by the test suite. +// +// If you add a new `required('FOO')` call in src/config.js, add a stub here. + +const dummy = { + WEBEX_BOT_TOKEN: 'test-bot-token', + WEBEX_SVC_CLIENT_ID: 'test-svc-client-id', + WEBEX_SVC_CLIENT_SECRET: 'test-svc-client-secret', + TWILIO_ACCOUNT_SID: 'test-twilio-sid', + TWILIO_AUTH_TOKEN: 'test-twilio-token', + SIW_USERNAME: 'test-siw-user', + SIW_PASSWORD: 'test-siw-password', + GOOGLE_API_KEY: 'test-google-key', + WS_TOKEN: 'test-ws-token', +}; + +for (const [key, value] of Object.entries(dummy)) { + if (process.env[key] === undefined || process.env[key] === '') { + process.env[key] = value; + } +} diff --git a/test/stepRunner.test.js b/test/stepRunner.test.js new file mode 100644 index 0000000..5c2afc0 --- /dev/null +++ b/test/stepRunner.test.js @@ -0,0 +1,67 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { runStep } from '../src/flows/stepRunner.js'; + +// Minimal bot double: records every `say` call so we can assert on the +// success/failure blockquote UX without touching a real Webex framework. +function makeBot() { + const said = []; + return { + said, + say(format, text) { + said.push({ format, text }); + }, + }; +} + +describe('runStep', () => { + let bot; + beforeEach(() => { + bot = makeBot(); + }); + + it('returns the resolved value and shows a success blockquote', async () => { + const result = await runStep(bot, 'Did the thing', async () => 42); + assert.equal(result, 42); + assert.equal(bot.said.length, 1); + assert.match(bot.said[0].text, /success.*Did the thing/); + }); + + it('honors an override success message', async () => { + await runStep(bot, 'internal-desc', async () => 'ok', { successMessage: 'Nice.' }); + assert.match(bot.said[0].text, /success.*Nice\./); + }); + + it('swallows non-critical failures, returns undefined, and shows a failure blockquote', async () => { + const result = await runStep(bot, 'That step', async () => { + throw new Error('nope'); + }); + assert.equal(result, undefined); + assert.equal(bot.said.length, 1); + assert.match(bot.said[0].text, /failure.*Error: That step/); + // Non-critical failures MUST NOT tag the message as aborting. + assert.doesNotMatch(bot.said[0].text, /aborting/); + }); + + it('re-throws on a critical failure and annotates the message', async () => { + await assert.rejects( + runStep( + bot, + 'Enabled location for Webex Calling', + async () => { + throw new Error('cascade'); + }, + { critical: true }, + ), + /cascade/, + ); + assert.equal(bot.said.length, 1); + assert.match(bot.said[0].text, /aborting \(critical step\)/); + }); + + it('works when bot is undefined (script/CLI usage)', async () => { + const result = await runStep(undefined, 'headless', async () => 'ok'); + assert.equal(result, 'ok'); + }); +}); diff --git a/test/webexClient.test.js b/test/webexClient.test.js new file mode 100644 index 0000000..2f33a5c --- /dev/null +++ b/test/webexClient.test.js @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseNextLink } from '../src/webex/client.js'; + +// RFC-5988 Link headers: webexListAll walks these to page through big +// list endpoints (locations, users, etc). If this parser breaks silently, +// list calls only return the first page. +describe('parseNextLink', () => { + it('extracts the URL from a next-only Link header', () => { + const header = '; rel="next"'; + assert.equal(parseNextLink(header), 'https://webexapis.com/v1/people?cursor=abc'); + }); + + it('picks the next rel out of a multi-rel header', () => { + const header = + '; rel="next", ' + + '; rel="first"'; + assert.equal(parseNextLink(header), 'https://webexapis.com/v1/people?cursor=abc'); + }); + + it('accepts unquoted rel values', () => { + const header = '; rel=next'; + assert.equal(parseNextLink(header), 'https://webexapis.com/v1/people?cursor=xyz'); + }); + + it('returns null when there is no next rel', () => { + const header = + '; rel="prev", ' + + '; rel="first"'; + assert.equal(parseNextLink(header), null); + }); + + it('returns null for missing / empty headers', () => { + assert.equal(parseNextLink(null), null); + assert.equal(parseNextLink(undefined), null); + assert.equal(parseNextLink(''), null); + }); +});