import { createHmac } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, } from "@suaveplan/testing/runner"; import { jsonResponder, type RecordingServer, startRecordingServer, } from "../../tests/setup/http-server.js"; import { SMS_PROVIDERS_CODES } from "../error-codes/error-codes.js"; import type { OutboundMessage } from "../types/provider.js"; import { PlivoProvider } from "./plivo.js"; let server: RecordingServer; afterEach(async () => { await server.close(); }); function makeProvider(config: { authId?: string; authToken?: string } = {}) { return new PlivoProvider({ authId: config.authId ?? "A1", authToken: config.authToken ?? "T1", baseUrl: server.origin, fetch: server.fetch, }); } const SEND_RESPONSE = { api_id: "api-uuid", message: "message(s) queued", message_uuid: ["msg-uuid-1"], }; const GET_RESPONSE = { api_id: "api-uuid", message_uuid: "msg-uuid-1", to_number: "+15551234567", message_state: "delivered", message_time: "2026-04-24T10:00:00Z", error_code: "000", }; describe("PlivoProvider — construction + send", () => { beforeEach(async () => { server = await startRecordingServer(jsonResponder(SEND_RESPONSE, 202)); }); it("requires authId + authToken", { timeout: 5000 }, () => { expect(() => makeProvider({ authId: "" })).toThrow(); expect(() => makeProvider({ authToken: "" })).toThrow(); }); it("POSTs Account/{id}/Message/ with Basic auth + JSON body", { timeout: 5000, }, async () => { const p = makeProvider(); const r = await p.send({ to: "+15551234567", from: "+15559999999", body: "hi", }); const req = server.requests[0]!; expect(req.method).toBe("POST"); expect(req.path).toBe("/v1/Account/A1/Message/"); expect(req.headers.authorization).toBe( `Basic ${Buffer.from("A1:T1").toString("base64")}` ); expect(req.headers["content-type"]).toContain("application/json"); const parsed = JSON.parse(req.body) as Record; expect(parsed).toEqual({ src: "+15559999999", dst: "+15551234567", text: "hi", }); expect(r.messageId).toBe("msg-uuid-1"); expect(r.status).toBe("queued"); }); it("forwards MMS media + flips type to mms", { timeout: 5000, }, async () => { const p = makeProvider(); await p.send({ to: "+1", from: "+1", body: "hi", mediaUrls: ["https://cdn/a.png"], } as OutboundMessage); const parsed = JSON.parse(server.requests[0]!.body) as Record< string, unknown >; expect(parsed["media_urls"]).toEqual(["https://cdn/a.png"]); expect(parsed["type"]).toBe("mms"); }); it("defaults src to empty when from is undefined", { timeout: 5000, }, async () => { const p = makeProvider(); await p.send({ to: "+1", body: "hi" }); const parsed = JSON.parse(server.requests[0]!.body) as Record< string, unknown >; expect(parsed["src"]).toBe(""); }); it("returns empty messageId when message_uuid[] is absent", { timeout: 5000, }, async () => { await server.close(); server = await startRecordingServer( jsonResponder({ ...SEND_RESPONSE, message_uuid: [] }, 202) ); const p = makeProvider(); const r = await p.send({ to: "+1", from: "+1", body: "hi" }); expect(r.messageId).toBe(""); }); }); describe("PlivoProvider — getStatus", () => { beforeEach(async () => { server = await startRecordingServer(jsonResponder(GET_RESPONSE)); }); it("GETs the Message/{uuid}/ resource", { timeout: 5000 }, async () => { const p = makeProvider(); const r = await p.getStatus("msg-uuid-1"); expect(server.requests[0]!.path).toBe( "/v1/Account/A1/Message/msg-uuid-1/" ); expect(r.status).toBe("delivered"); expect(r.errorCode).toBe("000"); }); it("defaults the timestamp when message_time is absent", { timeout: 5000, }, async () => { await server.close(); server = await startRecordingServer( jsonResponder({ ...GET_RESPONSE, message_time: undefined }) ); const p = makeProvider(); const r = await p.getStatus("msg"); expect(r.timestamp).toBeInstanceOf(Date); }); }); describe("PlivoProvider — mapStatus", () => { beforeEach(async () => { server = await startRecordingServer(jsonResponder({})); }); it.each([ ["queued", "queued"], ["sent", "sent"], ["delivered", "delivered"], ["undelivered", "undelivered"], ["failed", "failed"], ["rejected", "failed"], ["wat", "failed"], ])("%s → %s", { timeout: 5000 }, (raw, mapped) => { expect(makeProvider().mapStatus(raw)).toBe(mapped); }); }); function plivoSign( token: string, url: string, nonce: string, params: Record ): string { const sortedKeys = Object.keys(params).sort(); const pairs = sortedKeys.map((k) => `${k}=${params[k]}`).join(","); return createHmac("sha256", token) .update(`${url}${nonce}${pairs}`) .digest("base64"); } describe("PlivoProvider — verifyWebhookSignature", () => { beforeEach(async () => { server = await startRecordingServer(jsonResponder({})); }); it("accepts a valid signature", { timeout: 5000 }, async () => { const url = "https://app/webhooks/plivo"; const nonce = "n123"; const params = { From: "+1", To: "+2", Text: "hi", MessageUUID: "u" }; const sig = plivoSign("T1", url, nonce, params); const ok = await makeProvider().verifyWebhookSignature( url, nonce, params, sig ); expect(ok).toBe(true); }); it("rejects a tampered signature", { timeout: 5000 }, async () => { const url = "https://app/webhooks/plivo"; const sig = plivoSign("T1", url, "n", { a: "1" }); await expect( makeProvider().verifyWebhookSignature( url, "n", { a: "1" }, `${sig.slice(0, -1)}!` ) ).rejects.toMatchObject({ code: SMS_PROVIDERS_CODES.SIGNATURE_INVALID, }); }); }); describe("PlivoProvider — parseInbound", () => { beforeEach(async () => { server = await startRecordingServer(jsonResponder({})); }); it("identifies delivery receipts", { timeout: 5000 }, () => { expect( makeProvider().parseInbound({ MessageUUID: "u", Status: "delivered", ErrorCode: "000", }) ).toEqual({ type: "delivery_receipt", messageId: "u", status: "delivered", errorCode: "000", }); }); it("identifies DLR without errorCode when ErrorCode absent", { timeout: 5000, }, () => { expect( makeProvider().parseInbound({ MessageUUID: "u", Status: "delivered", }) ).toEqual({ type: "delivery_receipt", messageId: "u", status: "delivered", }); }); it("identifies STOP / YES / HELP", { timeout: 5000 }, () => { const p = makeProvider(); expect(p.parseInbound({ From: "+1", Text: "stop" })).toMatchObject({ type: "opt_out", }); expect(p.parseInbound({ From: "+1", Text: "YES" })).toMatchObject({ type: "opt_in", }); expect(p.parseInbound({ From: "+1", Text: "HELP" })).toMatchObject({ type: "help", }); }); it("returns message_received with providerId when MessageUUID present", { timeout: 5000, }, () => { expect( makeProvider().parseInbound({ From: "+1", To: "+2", Text: "free-form", MessageUUID: "u", }) ).toEqual({ type: "message_received", from: "+1", to: "+2", body: "free-form", providerId: "u", }); }); it("falls back to lowercase 'text' when 'Text' is absent", { timeout: 5000, }, () => { expect( makeProvider().parseInbound({ From: "+1", text: "lower" }) ).toEqual({ type: "message_received", from: "+1", to: "", body: "lower", }); }); });