212 lines
7.5 KiB
TypeScript
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();
|
|
});
|
|
});
|