Initial Commit
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user