175 lines
5.0 KiB
TypeScript
175 lines
5.0 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
import {
|
|
SMS_NOT_CONFIGURED,
|
|
consoleSmsSender,
|
|
resetSmsNotConfiguredWarning,
|
|
sendSms,
|
|
sendViaTwilio,
|
|
} from "../../src/services/smsService.js";
|
|
|
|
const TWILIO_KEYS = [
|
|
"TWILIO_ACCOUNT_SID",
|
|
"TWILIO_AUTH_TOKEN",
|
|
"TWILIO_FROM_NUMBER",
|
|
"TWILIO_MESSAGING_SERVICE_SID",
|
|
"NODE_ENV",
|
|
] as const;
|
|
|
|
let saved: Record<string, string | undefined>;
|
|
|
|
beforeEach(() => {
|
|
saved = {};
|
|
for (const key of TWILIO_KEYS) {
|
|
saved[key] = process.env[key];
|
|
delete process.env[key];
|
|
}
|
|
resetSmsNotConfiguredWarning();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const key of TWILIO_KEYS) {
|
|
if (saved[key] === undefined) delete process.env[key];
|
|
else process.env[key] = saved[key];
|
|
}
|
|
});
|
|
|
|
type FetchArgs = { url: string; init: RequestInit };
|
|
|
|
function stubFetch(
|
|
response: { ok: boolean; status: number; body: unknown },
|
|
captured: FetchArgs[]
|
|
): typeof fetch {
|
|
return (async (url: string, init: RequestInit) => {
|
|
captured.push({ url, init });
|
|
return {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
json: async () => response.body,
|
|
};
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
describe("sendViaTwilio", () => {
|
|
const credentials = {
|
|
accountSid: "AC123",
|
|
authToken: "secret-token",
|
|
from: { From: "+15550000000" },
|
|
};
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it("posts a form-encoded message and returns the sid", async () => {
|
|
const captured: FetchArgs[] = [];
|
|
globalThis.fetch = stubFetch(
|
|
{ ok: true, status: 201, body: { sid: "SM999" } },
|
|
captured
|
|
);
|
|
|
|
const result = await sendViaTwilio(credentials, "+15555550123", "hi");
|
|
assert.deepEqual(result, { status: "sent", messageId: "SM999" });
|
|
|
|
assert.equal(captured.length, 1);
|
|
assert.equal(
|
|
captured[0].url,
|
|
"https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json"
|
|
);
|
|
const headers = captured[0].init.headers as Record<string, string>;
|
|
assert.equal(
|
|
headers["Content-Type"],
|
|
"application/x-www-form-urlencoded"
|
|
);
|
|
assert.equal(
|
|
headers.Authorization,
|
|
"Basic " + Buffer.from("AC123:secret-token").toString("base64")
|
|
);
|
|
const form = new URLSearchParams(captured[0].init.body as string);
|
|
assert.equal(form.get("To"), "+15555550123");
|
|
assert.equal(form.get("Body"), "hi");
|
|
assert.equal(form.get("From"), "+15550000000");
|
|
});
|
|
|
|
it("sends MessagingServiceSid when that is how it is configured", async () => {
|
|
const captured: FetchArgs[] = [];
|
|
globalThis.fetch = stubFetch(
|
|
{ ok: true, status: 201, body: { sid: "SM1" } },
|
|
captured
|
|
);
|
|
await sendViaTwilio(
|
|
{
|
|
accountSid: "AC123",
|
|
authToken: "t",
|
|
from: { MessagingServiceSid: "MG9" },
|
|
},
|
|
"+15555550123",
|
|
"hi"
|
|
);
|
|
const form = new URLSearchParams(captured[0].init.body as string);
|
|
assert.equal(form.get("MessagingServiceSid"), "MG9");
|
|
assert.equal(form.get("From"), null);
|
|
});
|
|
|
|
it("reports the provider message on an error response", async () => {
|
|
globalThis.fetch = stubFetch(
|
|
{ ok: false, status: 400, body: { message: "Invalid 'To'" } },
|
|
[]
|
|
);
|
|
assert.deepEqual(await sendViaTwilio(credentials, "+1", "hi"), {
|
|
status: "failed",
|
|
error: "Invalid 'To'",
|
|
});
|
|
});
|
|
|
|
it("fails when a 2xx response carries no sid", async () => {
|
|
globalThis.fetch = stubFetch({ ok: true, status: 200, body: {} }, []);
|
|
const result = await sendViaTwilio(credentials, "+15555550123", "hi");
|
|
assert.equal(result.status, "failed");
|
|
});
|
|
|
|
it("turns a transport throw into a failed result", async () => {
|
|
globalThis.fetch = (async () => {
|
|
throw new Error("network down");
|
|
}) as unknown as typeof fetch;
|
|
assert.deepEqual(await sendViaTwilio(credentials, "+15555550123", "hi"), {
|
|
status: "failed",
|
|
error: "network down",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("sendSms configuration paths", () => {
|
|
it("fails with SMS_NOT_CONFIGURED when credentials are absent", async () => {
|
|
assert.deepEqual(await sendSms("+15555550123", "hi"), {
|
|
status: "failed",
|
|
error: SMS_NOT_CONFIGURED,
|
|
});
|
|
});
|
|
|
|
it("fails when an account is set but there is nothing to send from", async () => {
|
|
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
|
process.env.TWILIO_AUTH_TOKEN = "t";
|
|
const result = await sendSms("+15555550123", "hi");
|
|
assert.equal(result.status, "failed");
|
|
assert.equal(
|
|
result.status === "failed" ? result.error : "",
|
|
SMS_NOT_CONFIGURED
|
|
);
|
|
});
|
|
|
|
it("uses the console adapter under test-local with no credentials", async () => {
|
|
process.env.NODE_ENV = "test-local";
|
|
const result = await sendSms("+15555550123", "hi");
|
|
assert.equal(result.status, "sent");
|
|
});
|
|
});
|
|
|
|
describe("consoleSmsSender", () => {
|
|
it("reports sent without touching the network", async () => {
|
|
const result = await consoleSmsSender("+15555550123", "hi");
|
|
assert.equal(result.status, "sent");
|
|
});
|
|
});
|