Files
crowd-funder-for-time-pwa/src/test/smsNotificationApi.test.ts
T

228 lines
6.6 KiB
TypeScript

import {
authorizeSmsAlertSearch,
listSmsPhones,
registerSmsPhone,
SmsApiError,
smsErrorMessage,
} from "@/services/notifications/smsNotificationApi";
import { createEndorserJwtForDid } from "@/libs/endorserServer";
import { buildAlertAuthorizationBody } from "@/services/notifications/alertAuthorizationBatch";
jest.mock("@/libs/endorserServer", () => ({
createEndorserJwtForDid: jest.fn(),
}));
jest.mock("@/utils/logger", () => ({ logger: { warn: jest.fn() } }));
jest.mock("@/services/notifications/NotificationDebugConfig", () => ({
getNotificationApiBaseUrl: () => "https://notify.example",
}));
jest.mock("@/services/notifications/alertAuthorizationBatch", () => ({
buildAlertAuthorizationBody: jest.fn(),
}));
const mockedSign = createEndorserJwtForDid as unknown as jest.Mock;
const mockedFetch = jest.fn();
global.fetch = mockedFetch as unknown as typeof fetch;
const DID = "did:ethr:0x0000000000000000000000000000000000000001";
const FALLBACK = "Something went wrong.";
function answer(status: number, body: unknown) {
mockedFetch.mockResolvedValueOnce({
ok: status >= 200 && status < 300,
status,
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
});
}
async function refusal(status: number, body: unknown): Promise<SmsApiError> {
answer(status, body);
try {
await registerSmsPhone(DID, "555-555-0123");
} catch (error) {
if (error instanceof SmsApiError) {
return error;
}
throw error;
}
throw new Error("expected the call to be refused");
}
beforeEach(() => {
mockedFetch.mockReset();
mockedSign.mockReset().mockResolvedValue("signed");
});
describe("listSmsPhones", () => {
it("returns the service's body and signs a claim without a number", async () => {
const body = {
success: true,
phones: [
{
phoneNumber: "+15555550123",
verified: true,
verifiedAt: "2026-09-01T00:00:00.000Z",
createdAt: "2026-08-31T00:00:00.000Z",
},
{
phoneNumber: "+15555550199",
verified: false,
verifiedAt: null,
createdAt: "2026-09-02T00:00:00.000Z",
},
],
};
answer(200, body);
await expect(listSmsPhones(DID)).resolves.toEqual(body);
expect(mockedSign).toHaveBeenCalledWith(
DID,
{
claim: {
"@context": "https://giftopia.tech",
"@type": "SmsNotificationAction",
action: "list-phones",
},
},
300,
);
expect(mockedFetch.mock.calls[0][0]).toBe(
"https://notify.example/notify-sms/phone",
);
});
it("binds the claim and the query to a queried number", async () => {
answer(200, { success: true, phones: [], phoneNumber: "+15555550123" });
await listSmsPhones(DID, "(555) 555-0123");
expect(mockedSign.mock.calls[0][1].claim.phoneNumber).toBe("+15555550123");
expect(mockedFetch.mock.calls[0][0]).toBe(
"https://notify.example/notify-sms/phone?phoneNumber=%2B15555550123",
);
});
});
describe("refusals", () => {
it("keeps a coded refusal's extra fields", async () => {
const error = await refusal(400, {
success: false,
error: "SMS_CODE_MISMATCH",
message: "That code does not match.",
attemptsRemaining: 2,
});
expect(error.code).toBe("SMS_CODE_MISMATCH");
expect(error.status).toBe(400);
expect(error.message).toBe("That code does not match.");
expect(smsErrorMessage(error, FALLBACK)).toBe(
"That code is not right. 2 tries left.",
);
});
it("reads the identity limit from the refusal", async () => {
const error = await refusal(409, {
success: false,
error: "SMS_PHONE_DID_LIMIT",
message: "This number already carries the maximum number of identities.",
limit: 5,
verifiedCount: 5,
});
expect(smsErrorMessage(error, FALLBACK)).toBe(
"This number already has 5 identities on it, which is the limit.",
);
});
it("does not show an auth stage's server-log message", async () => {
const error = await refusal(401, {
success: false,
message: "Unauthorized. See server logs at 2026-09-13T12:00:00.000Z",
});
expect(error.code).toBe("");
expect(error.response).toBeUndefined();
expect(smsErrorMessage(error, FALLBACK)).toBe(
"The notification server could not confirm your identity. Please try again.",
);
});
it("names an unreachable Endorser for an uncoded 503", async () => {
const error = await refusal(503, {
success: false,
message: "Authentication service unavailable. See server logs at now",
});
expect(smsErrorMessage(error, FALLBACK)).toBe(
"The notification server cannot confirm identities right now. Please try again later.",
);
});
it("falls back for an answer that is not the service's JSON", async () => {
const error = await refusal(502, "<html>Bad Gateway</html>");
expect(error.code).toBe("");
expect(error.message).toBe("HTTP 502");
expect(smsErrorMessage(error, FALLBACK)).toBe(FALLBACK);
});
it("shows the service's message for a code this app does not know", async () => {
const error = await refusal(418, {
success: false,
error: "SMS_SOMETHING_NEW",
message: "A reason the app has no wording for.",
});
expect(smsErrorMessage(error, FALLBACK)).toBe(
"A reason the app has no wording for.",
);
});
it("uses the fallback for errors that are not SmsApiError", () => {
expect(smsErrorMessage(new TypeError("Failed to fetch"), FALLBACK)).toBe(
FALLBACK,
);
});
});
describe("authorizeSmsAlertSearch", () => {
const BODY = {
batchId: "b",
notifyHourUtc: 12,
notifyMinuteUtc: 0,
jwts: [],
};
beforeEach(() => {
(buildAlertAuthorizationBody as jest.Mock).mockResolvedValue(BODY);
});
async function sentBody(
...args: Parameters<typeof authorizeSmsAlertSearch> extends [
string,
number,
number,
...infer Rest,
]
? Rest
: never
): Promise<Record<string, unknown>> {
answer(200, { success: true });
await authorizeSmsAlertSearch(DID, 12, 0, ...args);
const [, init] = mockedFetch.mock.calls[0];
return JSON.parse(String(init.body));
}
it("asks for a rerun today only when told to", async () => {
expect(await sentBody({ endorserAfterCursor: "4" }, true)).toEqual({
...BODY,
endorserAfterCursor: "4",
rerunToday: true,
});
});
it("leaves rerunToday out by default, as the visit refresh uploads", async () => {
const body = await sentBody({ endorserAfterCursor: "4" });
expect(body).toEqual({ ...BODY, endorserAfterCursor: "4" });
expect(body).not.toHaveProperty("rerunToday");
});
});