feat(templates): edit / resubmit / delete lifecycle endpoints

Adds the missing lifecycle operations so users can manage existing
templates without leaving wacrm.

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

PATCH /api/whatsapp/templates/[id]
  Edits (and re-submits) an existing Meta-side template. Used by
  the "Edit" action on APPROVED rows and "Resubmit" on REJECTED /
  PAUSED rows — same operation, different UI labels. Meta replaces
  components wholesale and flips status back to PENDING for
  re-review. Status guard rejects edits on PENDING / DISABLED /
  IN_APPEAL / PENDING_DELETION and on local-only (no
  meta_template_id) rows. Honors WHATSAPP_TEMPLATES_DRY_RUN.

DELETE /api/whatsapp/templates/[id]
  Calls Meta `DELETE /{waba_id}/message_templates?name=X&hsm_id=Y`
  when meta_template_id is set — hsm_id is critical, without it
  Meta deletes EVERY language variant of the same name. Treats
  Meta 404 as a no-op (template already gone). Always removes the
  local row.

New Meta helpers
----------------

src/lib/whatsapp/meta-api.ts gains editMessageTemplate and
deleteMessageTemplate following the established named-args +
throwMetaError pattern. Eleven new fetch-mock tests assert the
exact wire shape — especially that hsm_id always rides along when
provided, which is the difference between "delete one variant"
and "delete every variant."

UI changes
----------

src/components/settings/template-manager.tsx:
  * Edit (pencil) button visible for APPROVED rows
  * Resubmit (rotate) button visible for REJECTED / PAUSED rows
    — same flow as Edit, different label
  * Delete now calls the route handler (so Meta gets the call too)
    with a spinner during the in-flight request
  * Dialog pre-fills form from the row when opened in edit mode
  * Dialog title / CTA / description switch between "New Message
    Template" + "Submit for Approval" and "Edit Message Template"
    + "Save & Resubmit"
  * Name + language inputs are disabled in edit mode (Meta keys
    templates by id; changing either is effectively a new template)
  * Closing the dialog clears editingId so the next open starts
    fresh

Design decision
---------------

One PATCH endpoint instead of the two endpoints the plan called
for (PATCH for edit, POST for resubmit). The Meta call is the same
operation either way — the difference is UI semantics, handled in
the component. Cuts duplication; easy to split later if needed.

Out of scope (final PR remaining in the sequence):
  * Webhook handler for real-time status (PR 5)
  * sendTemplateMessage media + button param support (PR 6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-29 15:57:24 +02:00
parent f329b8268f
commit a2100a3076
No known key found for this signature in database
4 changed files with 713 additions and 33 deletions

View File

@ -0,0 +1,269 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
import {
deleteMessageTemplate,
editMessageTemplate,
} from '@/lib/whatsapp/meta-api'
import {
validateTemplatePayload,
type TemplatePayload,
} from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
/**
* Per-template lifecycle endpoint.
*
* PATCH — edit an existing Meta-side template (and re-submit). Used
* by the "Edit" action on APPROVED rows and the "Resubmit"
* action on REJECTED / PAUSED rows. Meta replaces components
* wholesale on edit and bumps status back to PENDING.
*
* DELETE — remove the template on Meta (when meta_template_id is set,
* scoped to a single language variant via hsm_id) AND drop
* the local row. Local-only rows skip the Meta call.
*
* Initial submission (DRAFT → PENDING) lives at the sibling
* /submit endpoint — keep this route narrowly about lifecycle of
* already-submitted templates.
*/
const EDITABLE_STATUSES = new Set(['APPROVED', 'REJECTED', 'PAUSED'])
function isDryRun(): boolean {
return (
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
)
}
export async function PATCH(
request: Request,
context: { params: Promise<{ id: string }> },
) {
try {
const { id } = await context.params
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let payload: TemplatePayload
try {
payload = (await request.json()) as TemplatePayload
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
}
// RLS handles ownership, but we need the existing row to read
// meta_template_id and status — fetch explicitly.
const { data: existing, error: lookupErr } = await supabase
.from('message_templates')
.select('id, name, status, meta_template_id, language')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (lookupErr || !existing) {
return NextResponse.json({ error: 'Template not found.' }, { status: 404 })
}
if (!existing.meta_template_id) {
return NextResponse.json(
{
error:
'This template was never submitted to Meta — use New Template to submit it instead.',
},
{ status: 400 },
)
}
if (!EDITABLE_STATUSES.has(existing.status)) {
return NextResponse.json(
{
error: `Templates in status ${existing.status} cannot be edited. Allowed: APPROVED, REJECTED, PAUSED.`,
},
{ status: 400 },
)
}
if (payload.category === 'Authentication') {
return NextResponse.json(
{
error:
'AUTHENTICATION templates are not editable here — manage them in Meta WhatsApp Manager.',
},
{ status: 400 },
)
}
try {
validateTemplatePayload(payload)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Validation failed.' },
{ status: 400 },
)
}
const metaPayload = buildMetaTemplatePayload(payload)
if (!isDryRun()) {
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('user_id', user.id)
.single()
if (configError || !config) {
return NextResponse.json(
{ error: 'WhatsApp not configured.' },
{ status: 400 },
)
}
const accessToken = decrypt(config.access_token)
try {
await editMessageTemplate({
metaTemplateId: existing.meta_template_id,
accessToken,
components: metaPayload.components,
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Meta edit failed.'
await supabase
.from('message_templates')
.update({
submission_error: message,
last_submitted_at: new Date().toISOString(),
})
.eq('id', id)
return NextResponse.json({ error: message }, { status: 502 })
}
}
// Meta accepted the edit — status flips back to PENDING for review.
const { data: row, error: updErr } = await supabase
.from('message_templates')
.update({
category: payload.category,
header_type: payload.header_type ?? null,
header_content: payload.header_content ?? null,
header_media_url: payload.header_media_url ?? null,
header_handle: payload.header_handle ?? null,
body_text: payload.body_text,
footer_text: payload.footer_text ?? null,
buttons: payload.buttons ?? null,
sample_values: payload.sample_values ?? null,
status: 'PENDING',
submission_error: null,
rejection_reason: null,
last_submitted_at: new Date().toISOString(),
})
.eq('id', id)
.select()
.single()
if (updErr) {
return NextResponse.json(
{
error: `Edited on Meta but failed to save locally: ${updErr.message}. Run "Sync from Meta" to recover.`,
},
{ status: 500 },
)
}
return NextResponse.json({
success: true,
template: row,
dry_run: isDryRun(),
})
} catch (error) {
console.error('Error editing template:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to edit template.',
},
{ status: 500 },
)
}
}
export async function DELETE(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
try {
const { id } = await context.params
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: existing, error: lookupErr } = await supabase
.from('message_templates')
.select('id, name, meta_template_id')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (lookupErr || !existing) {
return NextResponse.json({ error: 'Template not found.' }, { status: 404 })
}
if (existing.meta_template_id && !isDryRun()) {
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('user_id', user.id)
.single()
if (configError || !config || !config.waba_id) {
return NextResponse.json(
{ error: 'WhatsApp not configured — cannot delete on Meta.' },
{ status: 400 },
)
}
const accessToken = decrypt(config.access_token)
try {
await deleteMessageTemplate({
wabaId: config.waba_id,
accessToken,
name: existing.name,
metaTemplateId: existing.meta_template_id,
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Meta delete failed.'
return NextResponse.json({ error: message }, { status: 502 })
}
}
const { error: delErr } = await supabase
.from('message_templates')
.delete()
.eq('id', id)
if (delErr) {
return NextResponse.json(
{
error: `Deleted on Meta but failed to delete locally: ${delErr.message}.`,
},
{ status: 500 },
)
}
return NextResponse.json({ success: true, dry_run: isDryRun() })
} catch (error) {
console.error('Error deleting template:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to delete template.',
},
{ status: 500 },
)
}
}

View File

@ -9,6 +9,8 @@ import {
RefreshCw, RefreshCw,
AlertCircle, AlertCircle,
X, X,
Pencil,
RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
@ -125,6 +127,11 @@ export function TemplateManager() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [form, setForm] = useState<TemplateFormData>(emptyForm); const [form, setForm] = useState<TemplateFormData>(emptyForm);
// Non-null when the dialog is editing an existing row — switches the
// submit handler from POST /submit to PATCH /[id] and changes the
// dialog title + CTA. Set to the template id to pre-fill from a row.
const [editingId, setEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Body variable indices — `[1, 2, 3]` for "{{1}} {{2}} {{3}}". We // Body variable indices — `[1, 2, 3]` for "{{1}} {{2}} {{3}}". We
// re-run the extractor on every render to keep the sample-value rows // re-run the extractor on every render to keep the sample-value rows
@ -208,6 +215,30 @@ export function TemplateManager() {
}; };
} }
function openEdit(template: MessageTemplate) {
setEditingId(template.id);
setForm({
name: template.name,
category: template.category,
language: template.language || 'en_US',
header_format: (template.header_type ?? 'none') as HeaderFormat,
header_content: template.header_content ?? '',
header_media_url: template.header_media_url ?? '',
header_sample: template.sample_values?.header?.[0] ?? '',
body_text: template.body_text,
body_samples: template.sample_values?.body ?? [],
footer_text: template.footer_text ?? '',
buttons: template.buttons ?? [],
});
setDialogOpen(true);
}
function openCreate() {
setEditingId(null);
setForm(emptyForm);
setDialogOpen(true);
}
async function handleSubmit() { async function handleSubmit() {
if (form.category === 'Authentication') { if (form.category === 'Authentication') {
toast.error( toast.error(
@ -217,22 +248,33 @@ export function TemplateManager() {
} }
try { try {
setSubmitting(true); setSubmitting(true);
const res = await fetch('/api/whatsapp/templates/submit', { const isEdit = editingId !== null;
method: 'POST', const url = isEdit
? `/api/whatsapp/templates/${editingId}`
: '/api/whatsapp/templates/submit';
const res = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildSubmitPayload()), body: JSON.stringify(buildSubmitPayload()),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(data?.error || `Submit failed (HTTP ${res.status})`); throw new Error(
data?.error || `${isEdit ? 'Edit' : 'Submit'} failed (HTTP ${res.status})`,
);
} }
toast.success( toast.success(
data.dry_run data.dry_run
? 'Template saved (dry-run — no Meta call)' ? isEdit
: 'Submitted to Meta — status will update once approved.', ? 'Template updated (dry-run — no Meta call)'
: 'Template saved (dry-run — no Meta call)'
: isEdit
? 'Edit submitted — Meta will re-review.'
: 'Submitted to Meta — status will update once approved.',
); );
setDialogOpen(false); setDialogOpen(false);
setForm(emptyForm); setForm(emptyForm);
setEditingId(null);
if (user) await fetchTemplates(user.id); if (user) await fetchTemplates(user.id);
} catch (err) { } catch (err) {
console.error('Submit error:', err); console.error('Submit error:', err);
@ -281,17 +323,26 @@ export function TemplateManager() {
} }
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (deletingId) return;
setDeletingId(id);
try { try {
const { error } = await supabase // Route handler scopes the Meta delete via hsm_id (so sibling
.from('message_templates') // language variants survive) and falls through to remove the
.delete() // local row. Local-only rows skip the Meta call.
.eq('id', id); const res = await fetch(`/api/whatsapp/templates/${id}`, {
if (error) throw error; method: 'DELETE',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.error || `Delete failed (HTTP ${res.status})`);
}
toast.success('Template deleted'); toast.success('Template deleted');
setTemplates((prev) => prev.filter((t) => t.id !== id)); setTemplates((prev) => prev.filter((t) => t.id !== id));
} catch (err) { } catch (err) {
console.error('Delete error:', err); console.error('Delete error:', err);
toast.error('Failed to delete template'); toast.error(err instanceof Error ? err.message : 'Failed to delete template');
} finally {
setDeletingId(null);
} }
} }
@ -359,10 +410,7 @@ export function TemplateManager() {
{syncing ? 'Syncing…' : 'Sync from Meta'} {syncing ? 'Syncing…' : 'Sync from Meta'}
</Button> </Button>
<Button <Button
onClick={() => { onClick={openCreate}
setForm(emptyForm);
setDialogOpen(true);
}}
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
<Plus className="size-4" /> <Plus className="size-4" />
@ -439,14 +487,48 @@ export function TemplateManager() {
</div> </div>
)} )}
</div> </div>
<Button <div className="flex items-center gap-0.5 shrink-0 ml-2">
variant="ghost" {statusKey === 'APPROVED' && (
size="icon" <Button
onClick={() => handleDelete(template.id)} variant="ghost"
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 shrink-0 ml-2" size="icon"
> onClick={() => openEdit(template)}
<Trash2 className="size-4" /> title="Edit — Meta will re-review the changes"
</Button> className="text-slate-400 hover:text-primary hover:bg-primary/10"
>
<Pencil className="size-4" />
</Button>
)}
{(statusKey === 'REJECTED' || statusKey === 'PAUSED') && (
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(template)}
title="Edit and resubmit for approval"
className="text-slate-400 hover:text-primary hover:bg-primary/10"
>
<RotateCcw className="size-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(template.id)}
disabled={deletingId === template.id}
title={
template.meta_template_id
? 'Delete from Meta and locally'
: 'Delete locally'
}
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30"
>
{deletingId === template.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@ -454,13 +536,25 @@ export function TemplateManager() {
</div> </div>
)} )}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setEditingId(null);
setForm(emptyForm);
}
}}
>
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="bg-slate-900 border-slate-700 sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-white">New Message Template</DialogTitle> <DialogTitle className="text-white">
{editingId ? 'Edit Message Template' : 'New Message Template'}
</DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-slate-400">
Build a template and submit it to Meta for approval. Once {editingId
approved, you can use it in broadcasts and the inbox. ? 'Save your changes to re-submit to Meta. Status will flip back to PENDING during review.'
: 'Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -483,10 +577,13 @@ export function TemplateManager() {
placeholder="e.g. order_confirmation" placeholder="e.g. order_confirmation"
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" disabled={editingId !== null}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 disabled:opacity-60 disabled:cursor-not-allowed"
/> />
<p className="text-[11px] text-slate-500"> <p className="text-[11px] text-slate-500">
Lowercase letters, digits, and underscores only. {editingId
? 'Name is fixed once a template exists on Meta — create a new template to change it.'
: 'Lowercase letters, digits, and underscores only.'}
</p> </p>
</div> </div>
@ -528,7 +625,8 @@ export function TemplateManager() {
onChange={(e) => onChange={(e) =>
setForm({ ...form, language: e.target.value }) setForm({ ...form, language: e.target.value })
} }
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" disabled={editingId !== null}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 disabled:opacity-60 disabled:cursor-not-allowed"
/> />
<datalist id="template-language-codes"> <datalist id="template-language-codes">
{COMMON_LANGUAGE_CODES.map((code) => ( {COMMON_LANGUAGE_CODES.map((code) => (
@ -536,8 +634,14 @@ export function TemplateManager() {
))} ))}
</datalist> </datalist>
<p className="text-[11px] text-slate-500"> <p className="text-[11px] text-slate-500">
Must match the exact code on Meta — <code>en_US</code> and{' '} {editingId
<code>en</code> are distinct. ? 'Language is fixed once a template exists on Meta.'
: (
<>
Must match the exact code on Meta — <code>en_US</code>{' '}
and <code>en</code> are distinct.
</>
)}
</p> </p>
</div> </div>
</div> </div>
@ -819,8 +923,10 @@ export function TemplateManager() {
{submitting ? ( {submitting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Submitting… {editingId ? 'Saving…' : 'Submitting…'}
</> </>
) : editingId ? (
'Save & Resubmit'
) : ( ) : (
'Submit for Approval' 'Submit for Approval'
)} )}

View File

@ -240,6 +240,87 @@ export async function submitMessageTemplate(
} }
} }
export interface EditMessageTemplateArgs {
/** Meta's template id (stored locally as `meta_template_id`). */
metaTemplateId: string
accessToken: string
/** Send the full components array — Meta replaces, not patches. */
components: MetaTemplateSubmitPayload['components']
/** Optional — only certain category transitions are allowed by Meta. */
category?: MetaTemplateSubmitPayload['category']
}
export interface EditMessageTemplateResult {
success: boolean
}
/**
* Edit an existing (APPROVED or REJECTED) message template.
*
* Meta caps edits at 10 per 30 days (and 1 per 24h for APPROVED
* templates). Every edit re-triggers review, so the status flips
* back to PENDING until Meta approves the new components.
*
* Note: PENDING / DISABLED / IN_APPEAL templates cannot be edited
* — the route handler enforces that before calling here.
*/
export async function editMessageTemplate(
args: EditMessageTemplateArgs
): Promise<EditMessageTemplateResult> {
const { metaTemplateId, accessToken, components, category } = args
const body: Record<string, unknown> = { components }
if (category) body.category = category
const response = await fetch(`${META_API_BASE}/${metaTemplateId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(body),
})
if (!response.ok) {
await throwMetaError(response, `Meta API error: ${response.status}`)
}
const data = await response.json().catch(() => ({}))
return { success: data?.success !== false }
}
export interface DeleteMessageTemplateArgs {
wabaId: string
accessToken: string
name: string
/**
* Without `hsm_id`, Meta deletes EVERY language variant of the
* template with this `name`. Pass the row's `meta_template_id`
* to scope to a single variant.
*/
metaTemplateId?: string
}
/**
* Delete a message template on Meta. Pass `metaTemplateId` to scope
* to a single language variant — otherwise Meta nukes every variant
* sharing the same `name`.
*/
export async function deleteMessageTemplate(
args: DeleteMessageTemplateArgs
): Promise<void> {
const { wabaId, accessToken, name, metaTemplateId } = args
const params = new URLSearchParams({ name })
if (metaTemplateId) params.set('hsm_id', metaTemplateId)
const url = `${META_API_BASE}/${wabaId}/message_templates?${params.toString()}`
const response = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
})
// Treat a 404 as a no-op — the template is already gone on Meta's
// side, and we still want the local row removed.
if (response.status === 404) return
if (!response.ok) {
await throwMetaError(response, `Meta API error: ${response.status}`)
}
}
// ============================================================ // ============================================================
// Reactions // Reactions
// ============================================================ // ============================================================

View File

@ -0,0 +1,224 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
deleteMessageTemplate,
editMessageTemplate,
submitMessageTemplate,
} from './meta-api';
// We mock fetch and assert on the request URL/method/body — these
// helpers have no validation of their own (they trust the validators
// upstream), so the contract we care about is the exact wire shape.
function okResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
function errorResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
describe('submitMessageTemplate', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(
okResponse({ id: '123', status: 'PENDING', category: 'UTILITY' }),
);
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('POSTs to /{wabaId}/message_templates with the payload as JSON', async () => {
const result = await submitMessageTemplate({
wabaId: 'WABA1',
accessToken: 'tok',
payload: {
name: 't',
category: 'UTILITY',
language: 'en_US',
components: [{ type: 'BODY', text: 'hi' }],
},
});
expect(result).toEqual({ id: '123', status: 'PENDING', category: 'UTILITY' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/WABA1/message_templates');
expect(init.method).toBe('POST');
expect(init.headers.Authorization).toBe('Bearer tok');
expect(JSON.parse(init.body)).toEqual({
name: 't',
category: 'UTILITY',
language: 'en_US',
components: [{ type: 'BODY', text: 'hi' }],
});
});
it('throws Meta\'s error message on non-OK responses', async () => {
fetchMock.mockResolvedValueOnce(
errorResponse(429, {
error: { message: 'Rate limit (#80007).' },
}),
);
await expect(
submitMessageTemplate({
wabaId: 'W',
accessToken: 't',
payload: {
name: 'n',
category: 'UTILITY',
language: 'en_US',
components: [],
},
}),
).rejects.toThrow(/Rate limit/);
});
it('throws if Meta accepts but returns no id (data integrity guard)', async () => {
fetchMock.mockResolvedValueOnce(okResponse({ status: 'PENDING' }));
await expect(
submitMessageTemplate({
wabaId: 'W',
accessToken: 't',
payload: {
name: 'n',
category: 'UTILITY',
language: 'en_US',
components: [],
},
}),
).rejects.toThrow(/no id/);
});
});
describe('editMessageTemplate', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(okResponse({ success: true }));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('POSTs to /{templateId} with only `components` in the body by default', async () => {
await editMessageTemplate({
metaTemplateId: 'TMPL_42',
accessToken: 'tok',
components: [{ type: 'BODY', text: 'new body' }],
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/TMPL_42');
expect(url).not.toContain('message_templates');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({
components: [{ type: 'BODY', text: 'new body' }],
});
});
it('includes `category` when provided', async () => {
await editMessageTemplate({
metaTemplateId: 'TMPL_42',
accessToken: 'tok',
components: [{ type: 'BODY', text: 'x' }],
category: 'MARKETING',
});
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(init.body)).toEqual({
components: [{ type: 'BODY', text: 'x' }],
category: 'MARKETING',
});
});
it('returns success:true on Meta success', async () => {
expect(
await editMessageTemplate({
metaTemplateId: 'T',
accessToken: 't',
components: [],
}),
).toEqual({ success: true });
});
it('treats { success: false } as failure', async () => {
fetchMock.mockResolvedValueOnce(okResponse({ success: false }));
expect(
await editMessageTemplate({
metaTemplateId: 'T',
accessToken: 't',
components: [],
}),
).toEqual({ success: false });
});
});
describe('deleteMessageTemplate', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(okResponse({ success: true }));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('DELETEs with name only when no metaTemplateId is given', async () => {
await deleteMessageTemplate({
wabaId: 'W',
accessToken: 't',
name: 'order_confirmation',
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/W/message_templates');
expect(url).toContain('name=order_confirmation');
expect(url).not.toContain('hsm_id');
expect(init.method).toBe('DELETE');
});
it('scopes to one language variant by including hsm_id', async () => {
// This is THE bug the plan flagged: without hsm_id, Meta deletes
// every language variant of `name`. Verifying it's always sent.
await deleteMessageTemplate({
wabaId: 'W',
accessToken: 't',
name: 'order_confirmation',
metaTemplateId: '12345',
});
const [url] = fetchMock.mock.calls[0];
expect(url).toContain('name=order_confirmation');
expect(url).toContain('hsm_id=12345');
});
it('treats 404 as a no-op (template already gone on Meta)', async () => {
fetchMock.mockResolvedValueOnce(
errorResponse(404, { error: { message: 'not found' } }),
);
await expect(
deleteMessageTemplate({
wabaId: 'W',
accessToken: 't',
name: 'x',
metaTemplateId: 'y',
}),
).resolves.toBeUndefined();
});
it('throws on non-404 errors', async () => {
fetchMock.mockResolvedValueOnce(
errorResponse(500, { error: { message: 'boom' } }),
);
await expect(
deleteMessageTemplate({
wabaId: 'W',
accessToken: 't',
name: 'x',
metaTemplateId: 'y',
}),
).rejects.toThrow(/boom/);
});
});