Initial Commit
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user