mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
225 lines
6.4 KiB
TypeScript
225 lines
6.4 KiB
TypeScript
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/);
|
|
});
|
|
});
|