Files
suaveplan/packages/communication/messaging/sms/src/error-codes/error-codes.test.ts
2026-05-27 23:29:53 +01:00

212 lines
7.5 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import { DeliveryTracker } from "../delivery-tracker/delivery-tracker.js";
import { InMemorySmsProvider } from "../in-memory-provider/in-memory-provider.js";
import { RateLimiter } from "../rate-limiter/rate-limiter.js";
import { RetryPolicy } from "../retry/retry.js";
import { parseSmsMessage } from "../schemas/schemas.js";
import { SmsService } from "../service/sms-service.js";
import { TemplateRegistry } from "../templates/templates.js";
import {
SMS_CODES,
SmsAbortedError,
SmsConfigMissingError,
SmsForbiddenError,
SmsInvalidConfigError,
SmsInvalidNumberError,
SmsProviderError,
SmsProviderNotConfiguredError,
SmsRateLimitedError,
SmsReceiptNotFoundError,
SmsRecipientOptedOutError,
SmsScheduleCancelledError,
SmsSendFailedError,
SmsTemplateNotFoundError,
} from "./error-codes.js";
describe("SMS error-code registry", () => {
it("exposes the thirteen declared codes", { timeout: 5000 }, () => {
expect(Object.values(SMS_CODES).sort()).toEqual(
[
"SMS.ABORTED",
"SMS.CONFIG_MISSING",
"SMS.FORBIDDEN",
"SMS.INVALID_CONFIG",
"SMS.INVALID_NUMBER",
"SMS.PROVIDER_ERROR",
"SMS.PROVIDER_NOT_CONFIGURED",
"SMS.RATE_LIMITED",
"SMS.RECEIPT_NOT_FOUND",
"SMS.RECIPIENT_OPTED_OUT",
"SMS.SCHEDULE_CANCELLED",
"SMS.SEND_FAILED",
"SMS.TEMPLATE_NOT_FOUND",
].sort()
);
});
it("every SMS code is reachable from a production call site", {
timeout: 30_000,
}, async () => {
// SMS.PROVIDER_NOT_CONFIGURED
const noProvider = new SmsService();
await expect(
noProvider.send({ to: "+15551234567", body: "hi" })
).rejects.toBeInstanceOf(SmsProviderNotConfiguredError);
// SMS.INVALID_NUMBER
const svc = new SmsService({
provider: new InMemorySmsProvider(),
retry: { maxAttempts: 1 },
});
await expect(
svc.send({ to: "bad", body: "hi" })
).rejects.toBeInstanceOf(SmsInvalidNumberError);
// SMS.RECIPIENT_OPTED_OUT
const svc2 = new SmsService({
provider: new InMemorySmsProvider(),
retry: { maxAttempts: 1 },
});
svc2.optOut("+15551234567");
await expect(
svc2.send({ to: "+15551234567", body: "hi" })
).rejects.toBeInstanceOf(SmsRecipientOptedOutError);
// SMS.RATE_LIMITED
const svc3 = new SmsService({
provider: new InMemorySmsProvider(),
rateLimiter: { capacity: 1, refillPerSecond: 1 },
retry: { maxAttempts: 1 },
});
await svc3.send({ to: "+15551234567", body: "hi" });
await expect(
svc3.send({ to: "+15551234567", body: "hi" })
).rejects.toBeInstanceOf(SmsRateLimitedError);
// SMS.TEMPLATE_NOT_FOUND
const reg = new TemplateRegistry();
expect(() => reg.require("absent")).toThrow(SmsTemplateNotFoundError);
// SMS.INVALID_CONFIG
expect(() => parseSmsMessage({ to: "+1", body: "" })).toThrow(
SmsInvalidConfigError
);
// SMS.RECEIPT_NOT_FOUND
const tracker = new DeliveryTracker();
await expect(tracker.lookupOrFetch("absent")).rejects.toBeInstanceOf(
SmsReceiptNotFoundError
);
// SMS.ABORTED (pre-aborted)
const ctrl = new AbortController();
ctrl.abort();
const retry = new RetryPolicy({
maxAttempts: 1,
random: () => 0,
});
await expect(
retry.execute(async () => 1, { signal: ctrl.signal })
).rejects.toBeInstanceOf(SmsAbortedError);
// SMS.PROVIDER_ERROR (raw construct)
expect(() => {
throw new SmsProviderError();
}).toThrow(SmsProviderError);
// SMS.SEND_FAILED — exhausted retries with non-coded error
const svc4 = new SmsService({
provider: (() => {
const p = new InMemorySmsProvider();
p.failOn(() => true, new Error("transient"));
return p;
})(),
retry: { maxAttempts: 1 },
rateLimiter: { capacity: 100, refillPerSecond: 100 },
});
await expect(
svc4.send({ to: "+15559999999", body: "hi" })
).rejects.toBeInstanceOf(SmsSendFailedError);
// SMS.CONFIG_MISSING — synthetic constructable
expect(() => {
throw new SmsConfigMissingError();
}).toThrow(SmsConfigMissingError);
// SMS.FORBIDDEN — synthetic constructable
expect(() => {
throw new SmsForbiddenError();
}).toThrow(SmsForbiddenError);
// SMS.SCHEDULE_CANCELLED — via cancel handle
const svc5 = new SmsService({
provider: new InMemorySmsProvider(),
});
const handle = svc5.scheduleAt(new Date(Date.now() + 60_000), {
to: "+15551234567",
body: "later",
});
handle.cancel();
expect(() => {
throw new SmsScheduleCancelledError();
}).toThrow(SmsScheduleCancelledError);
});
it("rate limiter is independent of the service", { timeout: 5000 }, () => {
const r = new RateLimiter({ capacity: 1, refillPerSecond: 1 });
expect(r.tryConsume("x")).toBe(true);
});
it("every error class accepts a custom message + context", {
timeout: 5000,
}, () => {
const ctx = { messageId: "m1" };
const errs = [
new SmsInvalidNumberError({ message: "custom", context: ctx }),
new SmsInvalidConfigError({ message: "custom", context: ctx }),
new SmsProviderNotConfiguredError({
message: "custom",
context: ctx,
}),
new SmsProviderError({ message: "custom", context: ctx }),
new SmsSendFailedError({ message: "custom", context: ctx }),
new SmsTemplateNotFoundError({ message: "custom", context: ctx }),
new SmsRecipientOptedOutError({
message: "custom",
context: ctx,
}),
new SmsRateLimitedError({ message: "custom", context: ctx }),
new SmsForbiddenError({ message: "custom", context: ctx }),
new SmsConfigMissingError({ message: "custom", context: ctx }),
new SmsScheduleCancelledError({
message: "custom",
context: ctx,
}),
new SmsAbortedError({ message: "custom", context: ctx }),
new SmsReceiptNotFoundError({ message: "custom", context: ctx }),
];
for (const e of errs) {
expect(e.message).toBe("custom");
expect(e.context).toEqual(ctx);
}
});
it("constructs with cause + traceId branches", { timeout: 5000 }, () => {
const cause = new Error("inner");
const e = new SmsSendFailedError({ cause, traceId: "t1" });
expect(e.cause).toBe(cause);
expect(e.traceId).toBe("t1");
});
it("SmsInvalidConfigError merges context + issues", {
timeout: 5000,
}, () => {
const e = new SmsInvalidConfigError({
context: { extra: 1 },
issues: [{ code: "x", path: [], message: "y" }],
});
expect(e.context?.["extra"]).toBe(1);
expect(e.context?.["issues"]).toBeDefined();
});
});