228 lines
6.6 KiB
TypeScript
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");
|
|
});
|
|
});
|