16055cdc41
Reporter @Rekoo-PS confirmed the language fix unblocked sending, then
hit a second gap: their template uses only `{{1}} = event_name` +
`{{2}} = gallery_link`, but the legacy `buildComponents` hardcoded all
5 positional values from the `gallery_ready` shape (customer_name,
event_name, gallery_link, password_line, expiry_date). Meta rejected
with a parameter-count mismatch even after the language matched.
This adds a per-config slot list — which built-in values to send, and
in what positional order — so admins can match templates of any shape
without code changes.
## Schema (migration 138)
Additive `template_params` TEXT column on `whatsapp_configs` (default
empty string = legacy 5-slot behaviour for existing installs). Stored
as a JSON-serialized array of slot keys: `customer_name`, `event_name`,
`gallery_link`, `password_line`, `expiry_date`. Unknown / duplicate /
non-string entries are sanitized out at read time.
## Processor
- `parseTemplateParams(raw)` — defensive parser; falls back to the
5-slot default on empty / malformed / all-invalid input.
- `buildComponents(data, metaLang, params)` — emits ONLY the listed
slots in the listed order, computed via a small switch on slot key.
The password line still receives the locale-specific 🔒 label and
the empty-when-no-real-password sentinel handling.
- Processor reads `config.template_params` once per cycle and passes
the parsed array to `buildComponents` per message.
## Admin route
- GET surfaces `template_params` as the parsed array (default 5-slot
when null/empty).
- PUT round-trips the incoming array through `parseTemplateParams`
before persisting, so the stored value is always the canonical
sanitized JSON.
- Test send rebuilt to use the same `buildComponents` path so the
admin's test message matches their configured slot shape — a
reporter who configures 2 slots gets a 2-parameter test send, not
the legacy 5-parameter payload.
## UI
- `WhatsAppTab` gets a checkbox + up/down list under the Template
language field. Each slot shows its current `{{N}}` position when
checked, an em-dash when unchecked. Live preview below the list:
"Your template will receive: {{1}} = event_name, {{2}} = gallery_link".
- EN + DE i18n for the field labels, hint, preview, and per-slot
human-readable names.
## Tests
- 17 unit tests in `__tests__/utils/whatsappBuildComponents.test.js`
covering: parseTemplateParams sanitization (unknown keys, duplicates,
non-strings, malformed JSON, all-invalid fallback, pre-parsed array
acceptance) and buildComponents shape (reporter's 2-slot case,
reorder, empty list, locale-specific password label, password
sentinel handling, expiry omission).
- All 17 + the 34 existing networkValidation tests pass.
## Migration numbering
Sits at 138 on top of PR #649's migration 137. If #646 (Live Slideshow)
merges before this, #646's own 137 + 138 take precedence and this
needs renumbering to 139. Coordinated via PR #646's review thread.
## Honest caveat
Still no Meta Business API account on my side. Spec-built, sanitizer +
shape unit-tested, lint + tsc clean. End-to-end against Meta needs the
reporter (or a maintainer with an account) to verify. If a real
round-trip surfaces a mismatch, drop it in #647 and I'll iterate.
137 lines
5.0 KiB
JavaScript
137 lines
5.0 KiB
JavaScript
/**
|
|
* Unit tests for the WhatsApp template-parameter selection (#647 follow-up).
|
|
*
|
|
* Pins:
|
|
* - parseTemplateParams sanitizes unknown / non-string / duplicate keys,
|
|
* and falls back to the default 5-slot shape on empty / malformed input.
|
|
* - buildComponents emits ONLY the listed slots, in the listed order, so
|
|
* a 2-parameter template (event_name + gallery_link) sends exactly 2
|
|
* positional values — the reporter's exact case from issue #647.
|
|
* - The legacy 5-slot default still works unchanged for installs that
|
|
* haven't reconfigured.
|
|
*/
|
|
const {
|
|
buildComponents,
|
|
parseTemplateParams,
|
|
DEFAULT_TEMPLATE_PARAMS,
|
|
} = require('../../src/services/whatsappProcessor');
|
|
|
|
const baseData = {
|
|
customer_name: 'Aisha',
|
|
event_name: 'Wedding 2026',
|
|
gallery_link: 'https://picpeak.example/wedding-2026',
|
|
gallery_password: 'StrongPass!',
|
|
expiry_date: '2026-12-31T00:00:00Z',
|
|
};
|
|
|
|
describe('parseTemplateParams', () => {
|
|
test('returns the default 5-slot shape for empty / null / undefined input', () => {
|
|
expect(parseTemplateParams('')).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
|
expect(parseTemplateParams(null)).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
|
expect(parseTemplateParams(undefined)).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
|
});
|
|
|
|
test('returns the default shape for malformed JSON', () => {
|
|
expect(parseTemplateParams('{not json')).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
|
});
|
|
|
|
test('returns the default shape when JSON parses to a non-array', () => {
|
|
expect(parseTemplateParams('"event_name"')).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
|
expect(parseTemplateParams('{"a":1}')).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
|
});
|
|
|
|
test('preserves the reporter\'s 2-slot shape', () => {
|
|
const out = parseTemplateParams(JSON.stringify(['event_name', 'gallery_link']));
|
|
expect(out).toEqual(['event_name', 'gallery_link']);
|
|
});
|
|
|
|
test('drops unknown slot keys', () => {
|
|
const out = parseTemplateParams(JSON.stringify([
|
|
'event_name', 'unknown_slot', 'gallery_link', '__proto__',
|
|
]));
|
|
expect(out).toEqual(['event_name', 'gallery_link']);
|
|
});
|
|
|
|
test('drops duplicate slot keys (first wins)', () => {
|
|
const out = parseTemplateParams(JSON.stringify([
|
|
'event_name', 'gallery_link', 'event_name',
|
|
]));
|
|
expect(out).toEqual(['event_name', 'gallery_link']);
|
|
});
|
|
|
|
test('drops non-string entries', () => {
|
|
const out = parseTemplateParams(JSON.stringify([
|
|
'event_name', 42, null, { a: 1 }, 'gallery_link',
|
|
]));
|
|
expect(out).toEqual(['event_name', 'gallery_link']);
|
|
});
|
|
|
|
test('falls back to default when every entry is invalid', () => {
|
|
const out = parseTemplateParams(JSON.stringify([
|
|
'unknown_a', 'unknown_b', null, 7,
|
|
]));
|
|
expect(out).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
|
});
|
|
|
|
test('also accepts an already-parsed array (defensive)', () => {
|
|
const out = parseTemplateParams(['event_name', 'gallery_link']);
|
|
expect(out).toEqual(['event_name', 'gallery_link']);
|
|
});
|
|
});
|
|
|
|
describe('buildComponents', () => {
|
|
test('legacy default shape emits 5 positional values, gallery_ready order', () => {
|
|
const out = buildComponents(baseData, 'en_US');
|
|
expect(out).toHaveLength(5);
|
|
expect(out[0]).toBe('Aisha');
|
|
expect(out[1]).toBe('Wedding 2026');
|
|
expect(out[2]).toBe('https://picpeak.example/wedding-2026');
|
|
expect(out[3]).toBe('🔒 Password: StrongPass!');
|
|
// expiry date is locale-formatted but always non-empty for a valid date
|
|
expect(out[4]).toMatch(/\d{2}/);
|
|
});
|
|
|
|
test('reporter\'s 2-slot shape — event_name + gallery_link, in that order', () => {
|
|
const out = buildComponents(baseData, 'ar', ['event_name', 'gallery_link']);
|
|
expect(out).toEqual(['Wedding 2026', 'https://picpeak.example/wedding-2026']);
|
|
});
|
|
|
|
test('reorder: gallery_link first, event_name second', () => {
|
|
const out = buildComponents(baseData, 'en_US', ['gallery_link', 'event_name']);
|
|
expect(out).toEqual(['https://picpeak.example/wedding-2026', 'Wedding 2026']);
|
|
});
|
|
|
|
test('empty slot list emits an empty components array (admin opted into nothing)', () => {
|
|
const out = buildComponents(baseData, 'en_US', []);
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
test('password_line uses the locale-specific label when included', () => {
|
|
const out = buildComponents(baseData, 'ar', ['password_line']);
|
|
expect(out).toEqual(['🔒 كلمة المرور: StrongPass!']);
|
|
});
|
|
|
|
test('password_line is empty when no real password is set', () => {
|
|
const out = buildComponents(
|
|
{ ...baseData, gallery_password: '' },
|
|
'en_US',
|
|
['password_line'],
|
|
);
|
|
expect(out).toEqual(['']);
|
|
});
|
|
|
|
test('password_line is empty for the "No password required" sentinel', () => {
|
|
const out = buildComponents(
|
|
{ ...baseData, gallery_password: 'No password required' },
|
|
'en_US',
|
|
['password_line'],
|
|
);
|
|
expect(out).toEqual(['']);
|
|
});
|
|
|
|
test('omits expiry_date when omitted from the slot list', () => {
|
|
const out = buildComponents(baseData, 'en_US', ['event_name']);
|
|
expect(out).toEqual(['Wedding 2026']);
|
|
});
|
|
});
|