wacrm/src/lib/whatsapp/template-send-builder.test.ts
Arnas Donauskas 11475840e1
feat(templates): media headers + URL/copy-code button params on send
Closes the last hole left by PR 3 — until now, sendTemplateMessage
only emitted a body component, so templates with image/video/
document headers or URL-with-{{1}} buttons failed at send time
with Meta's "missing component" 400. After this PR, every
APPROVED template that wacrm can submit is also fully sendable.

New helper
----------

src/lib/whatsapp/template-send-builder.ts builds the per-send
Meta components array from a MessageTemplate row + caller-supplied
SendTimeParams. It's a different beast from template-components.ts
(which builds the SUBMISSION shape) because the send-time payload
has a different schema: header type-specific parameter objects,
button components with sub_type + index, body_text without a 2D
example wrapper, etc.

Auto-fills as much as possible from the template:

  * static IMAGE/VIDEO/DOCUMENT headers ride along using the row's
    header_media_url (or header_handle when present — preferred)
  * URL buttons without {{1}} need no per-send payload
  * QUICK_REPLY / PHONE_NUMBER buttons need nothing
  * COPY_CODE buttons fall back to the template's example so the
    most common "static promo code" case Just Works without UI

Variable-bearing fields require per-send values:

  * TEXT header with {{1}}        → messageParams.headerText
  * Body variables                 → messageParams.body[]
  * URL button with {{1}}          → messageParams.buttonParams[i]
  * COPY_CODE override             → messageParams.buttonParams[i]

19 unit tests assert the wire shape per scenario — header types,
URL button at correct index when QR buttons precede it, example
fallback, and the throw-with-specific-message paths.

sendTemplateMessage extension
-----------------------------

src/lib/whatsapp/meta-api.ts gains optional `template` +
`messageParams` args. When `template` is provided, the full
components array is built via the new helper. Legacy `params:
string[]` (body only) path is preserved as-is — old callers
behave identically. When both shapes are present, structured
messageParams.body wins.

Route + UI wiring
-----------------

POST /api/whatsapp/send and /api/whatsapp/broadcast now load the
template row by (user_id, name, language) and pass it through.
Broadcast loads once per request (not per recipient — avoids N+1).
Both accept `template_message_params` / per-recipient
`messageParams` from the client.

src/components/inbox/template-picker.tsx scans the selected
template for variable-bearing fields and renders an input per
need: header text variable, body variables (existing), URL
button suffixes. Per-button preview shows the final URL with
the substitution so the user sees what the recipient will see.
The callback shape changes to `(template, { body, headerText?,
buttonParams? })`; message-thread.tsx updated accordingly.

Out of scope
------------

Broadcast composer doesn't yet collect per-recipient header /
button params — the legacy body-only flow continues to work for
broadcasts. Templates with header / URL-button variables broadcast
fine if every recipient gets the same value via API call shape;
the UI for varying it per recipient lands in a follow-up.

This is the last PR of the Issue #147 overhaul sequence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:38:15 +02:00

265 lines
7.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { buildSendComponents } from './template-send-builder';
import type { MessageTemplate } from '@/types';
function row(overrides: Partial<MessageTemplate> = {}): MessageTemplate {
return {
id: 'row-1',
user_id: 'user-1',
name: 'order_confirmation',
category: 'Utility',
language: 'en_US',
body_text: 'Your order is on its way.',
created_at: '2026-01-01T00:00:00Z',
...overrides,
};
}
describe('buildSendComponents — body', () => {
it('returns [] for a fully-static template (no vars, no media header)', () => {
expect(buildSendComponents(row())).toEqual([]);
});
it('emits a body component when the template has variables', () => {
const components = buildSendComponents(
row({ body_text: 'Hi {{1}}, order {{2}} confirmed.' }),
{ body: ['John', 'ORD-42'] },
);
expect(components).toEqual([
{
type: 'body',
parameters: [
{ type: 'text', text: 'John' },
{ type: 'text', text: 'ORD-42' },
],
},
]);
});
it('throws when body has variables but caller supplied too few values', () => {
expect(() =>
buildSendComponents(
row({ body_text: 'Hi {{1}} {{2}}' }),
{ body: ['just one'] },
),
).toThrow(/2 variable\(s\) but only 1/);
});
it('trims extra body values silently (legacy callers may overshoot)', () => {
const components = buildSendComponents(
row({ body_text: 'Hi {{1}}' }),
{ body: ['John', 'extra', 'extra2'] },
);
expect(components).toEqual([
{ type: 'body', parameters: [{ type: 'text', text: 'John' }] },
]);
});
});
describe('buildSendComponents — header', () => {
it('skips static TEXT headers (template carries them)', () => {
expect(
buildSendComponents(
row({ header_type: 'text', header_content: 'Order Confirmation' }),
),
).toEqual([]);
});
it('emits a TEXT header component when {{1}} is present', () => {
const components = buildSendComponents(
row({ header_type: 'text', header_content: 'Hello {{1}}' }),
{ headerText: 'Sara' },
);
expect(components).toEqual([
{ type: 'header', parameters: [{ type: 'text', text: 'Sara' }] },
]);
});
it('throws when TEXT header has {{1}} but no value was supplied', () => {
expect(() =>
buildSendComponents(
row({ header_type: 'text', header_content: 'Hello {{1}}' }),
),
).toThrow(/Header text variable \{\{1\}\}/);
});
it('auto-includes IMAGE header from the stored sample URL', () => {
const components = buildSendComponents(
row({
header_type: 'image',
header_media_url: 'https://example.com/sample.jpg',
}),
);
expect(components).toEqual([
{
type: 'header',
parameters: [
{ type: 'image', image: { link: 'https://example.com/sample.jpg' } },
],
},
]);
});
it('prefers caller override URL over template sample', () => {
const components = buildSendComponents(
row({
header_type: 'video',
header_media_url: 'https://example.com/default.mp4',
}),
{ headerMediaUrl: 'https://example.com/custom.mp4' },
);
expect(components[0]).toEqual({
type: 'header',
parameters: [
{ type: 'video', video: { link: 'https://example.com/custom.mp4' } },
],
});
});
it('prefers media id over url when both are available', () => {
const components = buildSendComponents(
row({
header_type: 'document',
header_handle: '4::aBc',
header_media_url: 'https://x.com/doc.pdf',
}),
);
expect(components[0]).toEqual({
type: 'header',
parameters: [{ type: 'document', document: { id: '4::aBc' } }],
});
});
it('throws on media header with no link OR id available', () => {
expect(() =>
buildSendComponents(row({ header_type: 'image' })),
).toThrow(/requires a media link or id/);
});
});
describe('buildSendComponents — buttons', () => {
it('omits URL buttons without variables (template carries the URL)', () => {
const components = buildSendComponents(
row({
buttons: [
{ type: 'URL', text: 'Visit', url: 'https://example.com' },
],
}),
);
expect(components).toEqual([]);
});
it('emits a URL button component when the URL has {{1}}', () => {
const components = buildSendComponents(
row({
buttons: [
{ type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' },
],
}),
{ buttonParams: { 0: 'ORD-42' } },
);
expect(components).toEqual([
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: 'ORD-42' }],
},
]);
});
it('throws when URL button has {{1}} but no buttonParam was provided', () => {
expect(() =>
buildSendComponents(
row({
buttons: [
{ type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' },
],
}),
),
).toThrow(/URL button #1 uses \{\{1\}\}/);
});
it('uses the correct index when QR buttons precede the URL button', () => {
// sub_type:url at index "2" because two QUICK_REPLY buttons came first.
const components = buildSendComponents(
row({
buttons: [
{ type: 'QUICK_REPLY', text: 'Yes' },
{ type: 'QUICK_REPLY', text: 'No' },
{ type: 'URL', text: 'Open', url: 'https://x.com/{{1}}' },
],
}),
{ buttonParams: { 2: 'ORD-42' } },
);
const urlBtn = components.find((c) => c.type === 'button');
expect(urlBtn).toEqual({
type: 'button',
sub_type: 'url',
index: '2',
parameters: [{ type: 'text', text: 'ORD-42' }],
});
});
it('falls back to the template example for COPY_CODE buttons', () => {
const components = buildSendComponents(
row({
buttons: [
{ type: 'COPY_CODE', text: 'Copy', example: 'SUMMER20' },
],
}),
);
expect(components).toEqual([
{
type: 'button',
sub_type: 'copy_code',
index: '0',
parameters: [{ type: 'coupon_code', coupon_code: 'SUMMER20' }],
},
]);
});
it('overrides COPY_CODE code when caller supplies one', () => {
const components = buildSendComponents(
row({
buttons: [{ type: 'COPY_CODE', text: 'Copy', example: 'STATIC' }],
}),
{ buttonParams: { 0: 'PERSONAL_CODE' } },
);
expect((components[0] as { parameters: { coupon_code: string }[] })
.parameters[0].coupon_code).toBe('PERSONAL_CODE');
});
it('skips PHONE_NUMBER buttons entirely (no send-time params allowed)', () => {
const components = buildSendComponents(
row({
buttons: [
{ type: 'PHONE_NUMBER', text: 'Call', phone_number: '+15551234567' },
],
}),
);
expect(components).toEqual([]);
});
});
describe('buildSendComponents — end-to-end mix', () => {
it('orders components header → body → buttons and includes all', () => {
const components = buildSendComponents(
row({
header_type: 'image',
header_media_url: 'https://x.com/img.jpg',
body_text: 'Hi {{1}}',
buttons: [
{ type: 'QUICK_REPLY', text: 'Yes' },
{ type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' },
],
}),
{ body: ['John'], buttonParams: { 1: 'abc' } },
);
expect(components.map((c) => c.type)).toEqual(['header', 'body', 'button']);
// QUICK_REPLY at index 0 doesn't need send-time params, so only the
// URL button at index 1 emits a component.
expect((components[2] as { index: string }).index).toBe('1');
});
});