wacrm/src/lib/whatsapp/template-webhook.test.ts
Arnas Donauskas c7d780631b
feat(templates): webhook handler for real-time status / quality updates
Wires Meta's three template-lifecycle webhook events into the
existing /api/whatsapp/webhook route so APPROVED / REJECTED /
PAUSED transitions land locally without a manual Sync click.

Handled events
--------------

  message_template_status_update
    → flip status to the raw Meta enum (re-using normalizeStatus
      from PR 3 so PENDING_REVIEW collapses to PENDING etc.)
    → persist `reason` to rejection_reason on REJECTED so the
      red banner in template-manager shows what to fix
    → clear rejection_reason + submission_error on any other
      transition so a stale REJECTED message doesn't linger
      after re-approval

  message_template_quality_update
    → set quality_score (GREEN/YELLOW/RED). Unknown values land
      as NULL so the chip just disappears.

  message_template_components_update
    → info-log only for v1. Auto-persisting Meta's modified
      components would silently change what the user thought
      they submitted; instead we point them to "Sync from Meta"
      in the log line. A follow-up PR could surface a banner.

Lookup by meta_template_id (indexed in migration 014) — globally
unique per WABA, so no user_id filter needed. Unknown rows log a
warning instead of failing; >1 matches log a warning too in case
of cross-tenant drift.

New module
----------

src/lib/whatsapp/template-webhook.ts isolates the dispatch + each
handler. The route handler just calls
`handleTemplateWebhookChange(change, supabaseAdmin())` when
`isTemplateWebhookField(change.field)` matches, and the existing
messaging branches are short-circuited so a template event doesn't
try to read message-shaped fields off the value.

12 new tests cover: status APPROVED happy path, REJECTED with
reason, REJECTED missing reason → generic fallback, PENDING_REVIEW
normalization, missing meta_template_id no-op, unknown row warning,
quality score update, invalid quality value → NULL, components
update no-op, unknown field defensive no-op, and the field detector.

Setup requirement
-----------------

Subscribing to these fields is a one-time manual step in Meta App
Dashboard → WhatsApp → Configuration → Webhooks (no API for Cloud
API apps). Documented in a top-of-file comment block in
template-webhook.ts. Until it's done, status updates still arrive
via the manual "Sync from Meta" button — that fallback stays.

Out of scope (final PR in sequence):
  * sendTemplateMessage media + button param support (PR 6)

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

253 lines
7.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SupabaseClient } from '@supabase/supabase-js';
import {
handleTemplateWebhookChange,
isTemplateWebhookField,
} from './template-webhook';
// Tiny mock that records the .update payload and the .eq filter for
// inspection. Mirrors the surface this module actually uses on the
// Supabase client (.from().update().eq().select()) — anything beyond
// throws, so unintended calls fail loudly.
function makeSupabaseStub(
selectResult: { data: { id: string }[] | null; error: { message: string } | null } = {
data: [{ id: 'row-1' }],
error: null,
},
) {
const calls: {
table: string;
update?: Record<string, unknown>;
filter?: { column: string; value: unknown };
}[] = [];
const stub = {
from(table: string) {
const entry: (typeof calls)[number] = { table };
calls.push(entry);
return {
update(payload: Record<string, unknown>) {
entry.update = payload;
return {
eq(column: string, value: unknown) {
entry.filter = { column, value };
return {
select() {
return Promise.resolve(selectResult);
},
then(
onFulfilled: (
v: { error: { message: string } | null },
) => unknown,
) {
// Allow `await supabase.update().eq()` (no .select()).
return Promise.resolve({ error: selectResult.error }).then(
onFulfilled,
);
},
};
},
};
},
};
},
};
return { stub: stub as unknown as SupabaseClient, calls };
}
describe('isTemplateWebhookField', () => {
it('recognises the three template fields', () => {
expect(isTemplateWebhookField('message_template_status_update')).toBe(true);
expect(isTemplateWebhookField('message_template_quality_update')).toBe(true);
expect(isTemplateWebhookField('message_template_components_update')).toBe(
true,
);
});
it('rejects messaging fields', () => {
expect(isTemplateWebhookField('messages')).toBe(false);
expect(isTemplateWebhookField('message_status')).toBe(false);
});
});
describe('handleTemplateWebhookChange — status update', () => {
let supabaseCalls: ReturnType<typeof makeSupabaseStub>['calls'];
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'info').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('flips status to APPROVED and clears any rejection_reason', async () => {
const { stub, calls } = makeSupabaseStub();
supabaseCalls = calls;
await handleTemplateWebhookChange(
{
field: 'message_template_status_update',
value: {
event: 'APPROVED',
message_template_id: 12345,
message_template_name: 'order_confirmation',
message_template_language: 'en_US',
},
},
stub,
);
expect(supabaseCalls).toHaveLength(1);
expect(supabaseCalls[0].table).toBe('message_templates');
expect(supabaseCalls[0].filter).toEqual({
column: 'meta_template_id',
value: '12345', // coerced to string so the .eq matches the TEXT column
});
expect(supabaseCalls[0].update).toEqual({
status: 'APPROVED',
rejection_reason: null,
submission_error: null,
});
});
it('persists the reason field on REJECTED', async () => {
const { stub, calls } = makeSupabaseStub();
await handleTemplateWebhookChange(
{
field: 'message_template_status_update',
value: {
event: 'REJECTED',
message_template_id: 'TMPL_99',
reason: 'Template uses non-compliant language.',
},
},
stub,
);
expect(calls[0].update?.status).toBe('REJECTED');
expect(calls[0].update?.rejection_reason).toBe(
'Template uses non-compliant language.',
);
});
it('falls back to a generic reason when REJECTED has no `reason`', async () => {
const { stub, calls } = makeSupabaseStub();
await handleTemplateWebhookChange(
{
field: 'message_template_status_update',
value: { event: 'REJECTED', message_template_id: '7' },
},
stub,
);
expect(calls[0].update?.rejection_reason).toBe('Rejected by Meta');
});
it('normalises PENDING_REVIEW → PENDING (via shared normalizeStatus)', async () => {
const { stub, calls } = makeSupabaseStub();
await handleTemplateWebhookChange(
{
field: 'message_template_status_update',
value: { event: 'PENDING_REVIEW', message_template_id: '1' },
},
stub,
);
expect(calls[0].update?.status).toBe('PENDING');
});
it('logs and exits when meta_template_id is missing (no UPDATE issued)', async () => {
const { stub, calls } = makeSupabaseStub();
await handleTemplateWebhookChange(
{
field: 'message_template_status_update',
value: { event: 'APPROVED' },
},
stub,
);
expect(calls).toHaveLength(0);
});
it('logs a warning when the row is unknown locally (zero matches)', async () => {
const warn = vi.spyOn(console, 'warn');
const { stub } = makeSupabaseStub({ data: [], error: null });
await handleTemplateWebhookChange(
{
field: 'message_template_status_update',
value: {
event: 'APPROVED',
message_template_id: 'NEVER_SEEN',
message_template_name: 'mystery',
},
},
stub,
);
expect(warn).toHaveBeenCalled();
});
});
describe('handleTemplateWebhookChange — quality update', () => {
it('sets quality_score from new_quality_score', async () => {
const { stub, calls } = makeSupabaseStub();
await handleTemplateWebhookChange(
{
field: 'message_template_quality_update',
value: {
message_template_id: '99',
previous_quality_score: 'GREEN',
new_quality_score: 'YELLOW',
},
},
stub,
);
expect(calls[0].update).toEqual({ quality_score: 'YELLOW' });
expect(calls[0].filter).toEqual({
column: 'meta_template_id',
value: '99',
});
});
it('stores null for unrecognised quality scores', async () => {
const { stub, calls } = makeSupabaseStub();
await handleTemplateWebhookChange(
{
field: 'message_template_quality_update',
value: {
message_template_id: '99',
new_quality_score: 'PURPLE', // not a real Meta value
},
},
stub,
);
expect(calls[0].update).toEqual({ quality_score: null });
});
});
describe('handleTemplateWebhookChange — components update', () => {
it('is an info-log no-op (does not write to DB)', async () => {
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
const { stub, calls } = makeSupabaseStub();
await handleTemplateWebhookChange(
{
field: 'message_template_components_update',
value: {
message_template_id: '5',
message_template_name: 'x',
},
},
stub,
);
expect(calls).toHaveLength(0);
expect(info).toHaveBeenCalled();
});
});
describe('handleTemplateWebhookChange — unknown field', () => {
it('is a defensive no-op', async () => {
const { stub, calls } = makeSupabaseStub();
await handleTemplateWebhookChange(
// Pretend Meta added a new template_* field we don't know about.
// The route handler pre-filters via isTemplateWebhookField, but
// the dispatch should still be safe if the filter is bypassed.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ field: 'message_template_future_field' as any, value: {} },
stub,
);
expect(calls).toHaveLength(0);
});
});