wacrm/src/lib/whatsapp/template-send-builder.test.ts
Arnas Donauskas 4d23afc563
feat(templates): upload image for template headers; fix image-URL submit (#230)
Creating a template with an image header failed because the code sent
`example.header_url` — Meta only accepts a Resumable-Upload media handle
(`example.header_handle`) at creation time, never a plain URL.

- Resumable Upload: new `uploadResumableMedia` (meta-api) implements the
  two-step `/{app_id}/uploads` flow → media handle. Needs META_APP_ID.
- Handle derivation: `ensureImageHeaderHandle` fetches the header image
  (uploaded file OR pasted URL) and turns it into a handle before submit.
  Wired into both the submit and edit (PATCH) routes with actionable 400s
  (missing META_APP_ID, unreachable URL, wrong type, >5 MB). This fixes
  BOTH the new upload path and the previously-broken manual-URL path.
- Template builder: add an image file upload (account-scoped chat-media
  bucket via uploadAccountMedia) with preview + JPEG/PNG + 5 MB validation,
  alongside the URL field.
- Send-time fix: an approved image template now sends the public
  header_media_url as `link`; the resumable header_handle is NO LONGER
  used as a send media id (it's invalid for sends and would fail).
- META_APP_ID documented as required for image-header templates.

Scope: image headers (the issue). Helper is generic for video/document.
typecheck/lint/build green; 421 tests (10 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:00:54 +03:00

278 lines
8.2 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('does NOT use the resumable header_handle at send — falls back to the stored link', () => {
// header_handle is a template-CREATION sample handle, invalid as a
// send-time media id. The send must use the public link instead.
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: { link: 'https://x.com/doc.pdf' } }],
});
});
it('uses an explicit headerMediaId override as the media id', () => {
const components = buildSendComponents(
row({ header_type: 'image', header_media_url: 'https://x.com/s.jpg' }),
{ headerMediaId: '9:realMediaId' },
);
expect(components[0]).toEqual({
type: 'header',
parameters: [{ type: 'image', image: { id: '9:realMediaId' } }],
});
});
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');
});
});