301 lines
8.7 KiB
TypeScript
301 lines
8.7 KiB
TypeScript
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<string, unknown>;
|
|
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, string>
|
|
): 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",
|
|
});
|
|
});
|
|
});
|