wacrm/src/lib/whatsapp/template-validators.test.ts
Arnas Donauskas 3113738c7e
feat(templates): submit-to-Meta endpoint + component-based form builder
Closes the second half of #147. Users can now create a template in
wacrm and submit it to Meta for approval without leaving the app —
no more dummy local rows that fail with Meta error #132001 on send.

API surface
-----------

POST /api/whatsapp/templates/submit
  Auth → fetch whatsapp_config → validate payload → build Meta
  components → POST to /{waba_id}/message_templates → persist row
  with meta_template_id + status + sample_values +
  last_submitted_at. Per-field validators throw with specific
  messages before the network call, so misconfigured templates
  fail with "button #3 missing url" instead of a 400 from Meta.
  429s are detected and surfaced as "rate limit hit (100/hr)".

WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and
issues a synthetic `dry-run-<uuid>` meta_template_id so CI and
local dev can exercise the full form without a real WABA.

New modules
-----------

src/lib/whatsapp/template-validators.ts
  Pure functions encoding Meta's caps: 1024-char body, 60-char
  footer/header, 25-char button text, 10 buttons max, 2 URL / 1
  PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL
  example required when {{1}} is present, COPY_CODE example
  required, header media URL must be http(s), variables must be
  contiguous starting at {{1}}. 41 unit tests cover each rule.

src/lib/whatsapp/template-components.ts
  Translates our local row shape into Meta's components array
  (HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D
  array per spec, header_handle preferred over header_url when
  both are present). Snapshot tests assert the wire shape.

src/lib/whatsapp/template-status-normalize.ts
  Single source of truth for upstream→local status mapping.
  Replaces the duplicated normalizer in sync/route.ts; the
  upcoming webhook handler will share it too.

src/lib/whatsapp/meta-api.ts
  New submitMessageTemplate() helper following the existing
  named-args + throwMetaError pattern.

Form rewrite
------------

src/components/settings/template-manager.tsx replaces the flat
form with a component-based builder:

  * Header: format select (None / Text / Image / Video / Document)
    + conditional inputs — text + variable-aware sample row for
    TEXT, URL paste for media (file upload slot ready for the
    Resumable Upload follow-up)
  * Body: textarea with live {{N}} extraction → sample-value rows
    auto-appear and shrink as variables are added/removed
  * Footer: 60-char capped input
  * Buttons editor: add up to 10, per-row type picker
    (Quick Reply / URL / Phone / Copy Code), conditional URL +
    example + phone + code fields, X to remove
  * Card display now surfaces rejection_reason / submission_error
    (red banner) and quality_score (GREEN/YELLOW/RED chip)
  * AUTHENTICATION shows a "create in Meta Manager + Sync" banner
    and disables Submit — that category has a different shape
    (fixed body, mandatory OTP button) and lands in a follow-up

Side effects
------------

  * Broadcast Step 1 picker (step1-choose-template.tsx) now
    filters to status='APPROVED' to match the inbox picker —
    selecting a non-approved template would 400 on send.
  * .env.local.example documents META_APP_ID (Resumable Upload
    follow-up) and WHATSAPP_TEMPLATES_DRY_RUN.

Out of scope (subsequent PRs):
  * Edit / resubmit / delete endpoints (PR 4)
  * Webhook handler for real-time status (PR 5)
  * sendTemplateMessage media + button param support (PR 6)
  * Resumable Upload + AUTHENTICATION builder (follow-up)

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

278 lines
8.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
extractVariableIndices,
TEMPLATE_LIMITS,
validateBody,
validateButtons,
validateFooter,
validateHeader,
validateSampleValues,
validateTemplateName,
validateTemplatePayload,
type TemplatePayload,
} from './template-validators';
const baseValid: TemplatePayload = {
name: 'order_confirmation',
category: 'Utility',
language: 'en_US',
body_text: 'Your order is confirmed.',
};
describe('extractVariableIndices', () => {
it('returns sorted unique 1-based indices', () => {
expect(extractVariableIndices('Hi {{2}} and {{1}} {{2}}')).toEqual([1, 2]);
});
it('returns empty array for no variables', () => {
expect(extractVariableIndices('No vars here')).toEqual([]);
});
});
describe('validateTemplateName', () => {
it('accepts lowercase + digits + underscore', () => {
expect(() => validateTemplateName('order_v2')).not.toThrow();
});
it('rejects uppercase', () => {
expect(() => validateTemplateName('OrderV2')).toThrow(/lowercase/);
});
it('rejects empty', () => {
expect(() => validateTemplateName('')).toThrow(/required/);
});
it('rejects spaces and dashes', () => {
expect(() => validateTemplateName('order v2')).toThrow();
expect(() => validateTemplateName('order-v2')).toThrow();
});
});
describe('validateBody', () => {
it('rejects empty', () => {
expect(() => validateBody(' ')).toThrow(/required/);
});
it('rejects > 1024 chars', () => {
expect(() => validateBody('x'.repeat(TEMPLATE_LIMITS.bodyMaxLength + 1))).toThrow(
/exceeds 1024/,
);
});
it('rejects non-contiguous variables', () => {
expect(() => validateBody('Hi {{1}} {{3}}')).toThrow(/contiguous/);
});
it('accepts contiguous variables', () => {
expect(validateBody('Hi {{1}} {{2}}')).toEqual([1, 2]);
});
});
describe('validateFooter', () => {
it('accepts undefined', () => {
expect(() => validateFooter(undefined)).not.toThrow();
});
it('rejects > 60 chars', () => {
expect(() => validateFooter('x'.repeat(61))).toThrow(/60 chars/);
});
it('rejects variables in footer', () => {
expect(() => validateFooter('Powered by {{1}}')).toThrow(/cannot contain/);
});
});
describe('validateHeader', () => {
it('text header requires content', () => {
expect(() =>
validateHeader({ header_type: 'text', header_content: '' }),
).toThrow(/requires header_content/);
});
it('text header rejects > 60 chars', () => {
expect(() =>
validateHeader({ header_type: 'text', header_content: 'x'.repeat(61) }),
).toThrow(/60 chars/);
});
it('text header rejects more than one variable', () => {
expect(() =>
validateHeader({ header_type: 'text', header_content: '{{1}} {{2}}' }),
).toThrow(/at most one variable/);
});
it('text header requires variable to be {{1}}', () => {
expect(() =>
validateHeader({ header_type: 'text', header_content: 'Hello {{2}}' }),
).toThrow(/must be \{\{1\}\}/);
});
it('image header requires a URL or handle', () => {
expect(() => validateHeader({ header_type: 'image' })).toThrow(
/requires either/,
);
});
it('image header accepts a URL', () => {
expect(() =>
validateHeader({
header_type: 'image',
header_media_url: 'https://example.com/img.jpg',
}),
).not.toThrow();
});
it('image header rejects a non-URL string', () => {
expect(() =>
validateHeader({
header_type: 'image',
header_media_url: 'not a url',
}),
).toThrow(/valid URL/);
});
});
describe('validateButtons', () => {
it('accepts undefined / empty', () => {
expect(() => validateButtons(undefined)).not.toThrow();
expect(() => validateButtons([])).not.toThrow();
});
it('rejects > 10 buttons', () => {
const tooMany = Array.from({ length: 11 }, () => ({
type: 'QUICK_REPLY' as const,
text: 'Hi',
}));
expect(() => validateButtons(tooMany)).toThrow(/at most 10 buttons/);
});
it('rejects > 2 URL buttons', () => {
expect(() =>
validateButtons([
{ type: 'URL', text: 'a', url: 'https://x' },
{ type: 'URL', text: 'b', url: 'https://x' },
{ type: 'URL', text: 'c', url: 'https://x' },
]),
).toThrow(/At most 2 URL/);
});
it('rejects > 1 PHONE_NUMBER', () => {
expect(() =>
validateButtons([
{ type: 'PHONE_NUMBER', text: 'a', phone_number: '+1' },
{ type: 'PHONE_NUMBER', text: 'b', phone_number: '+2' },
]),
).toThrow(/At most 1 PHONE_NUMBER/);
});
it('rejects > 1 COPY_CODE', () => {
expect(() =>
validateButtons([
{ type: 'COPY_CODE', text: 'a', example: 'X' },
{ type: 'COPY_CODE', text: 'b', example: 'Y' },
]),
).toThrow(/At most 1 COPY_CODE/);
});
it('rejects QUICK_REPLY interleaved with CTA buttons', () => {
expect(() =>
validateButtons([
{ type: 'QUICK_REPLY', text: 'A' },
{ type: 'URL', text: 'B', url: 'https://x' },
{ type: 'QUICK_REPLY', text: 'C' },
]),
).toThrow(/cannot be interleaved/);
});
it('accepts QUICK_REPLY then CTA in correct order', () => {
expect(() =>
validateButtons([
{ type: 'QUICK_REPLY', text: 'A' },
{ type: 'QUICK_REPLY', text: 'B' },
{ type: 'URL', text: 'C', url: 'https://x' },
]),
).not.toThrow();
});
it('rejects empty button text', () => {
expect(() =>
validateButtons([{ type: 'QUICK_REPLY', text: '' }]),
).toThrow(/missing text/);
});
it('rejects URL button without url', () => {
expect(() =>
validateButtons([{ type: 'URL', text: 'Go', url: '' }]),
).toThrow(/missing url/);
});
it('rejects URL button with invalid url', () => {
expect(() =>
validateButtons([{ type: 'URL', text: 'Go', url: 'not-a-url' }]),
).toThrow(/invalid url/);
});
it('rejects URL with {{1}} but no example', () => {
expect(() =>
validateButtons([
{ type: 'URL', text: 'Go', url: 'https://x/{{1}}' },
]),
).toThrow(/Meta requires an example/);
});
it('rejects URL with non-{{1}} variable', () => {
expect(() =>
validateButtons([
{
type: 'URL',
text: 'Go',
url: 'https://x/{{2}}',
example: 'foo',
},
]),
).toThrow(/must be \{\{1\}\}/);
});
it('rejects PHONE_NUMBER without phone_number', () => {
expect(() =>
validateButtons([
{ type: 'PHONE_NUMBER', text: 'Call', phone_number: '' },
]),
).toThrow(/missing phone_number/);
});
it('rejects COPY_CODE without example', () => {
expect(() =>
validateButtons([{ type: 'COPY_CODE', text: 'Copy', example: '' }]),
).toThrow(/missing example/);
});
});
describe('validateSampleValues', () => {
it('rejects mismatched body sample count', () => {
expect(() =>
validateSampleValues(
{ ...baseValid, body_text: 'Hi {{1}}', sample_values: { body: [] } },
1,
0,
),
).toThrow(/exactly 1 sample/);
});
it('rejects empty sample values', () => {
expect(() =>
validateSampleValues(
{ ...baseValid, sample_values: { body: [' '] } },
1,
0,
),
).toThrow(/empty/);
});
it('accepts matching counts', () => {
expect(() =>
validateSampleValues(
{ ...baseValid, sample_values: { body: ['John'] } },
1,
0,
),
).not.toThrow();
});
});
describe('validateTemplatePayload — integration', () => {
it('passes for a minimal valid payload', () => {
expect(validateTemplatePayload(baseValid)).toEqual({
bodyVarCount: 0,
headerVarCount: 0,
});
});
it('passes with body variables + matching samples', () => {
expect(
validateTemplatePayload({
...baseValid,
body_text: 'Hi {{1}}, order {{2}} confirmed.',
sample_values: { body: ['John', 'ORD-42'] },
}),
).toEqual({ bodyVarCount: 2, headerVarCount: 0 });
});
it('throws on missing samples for body variables', () => {
expect(() =>
validateTemplatePayload({
...baseValid,
body_text: 'Hi {{1}}',
}),
).toThrow(/exactly 1 sample/);
});
});