Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
18 kB ยท 540 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541/** @jsxRuntime automatic @jsxImportSource react */
import "./test-preload.ts";import { expect } from "@std/expect";import { afterEach, beforeEach, describe, test } from "@std/testing/bdd";import { assertSpyCalls, type Stub, stub } from "@std/testing/mock";import { render } from "react-email";
import type { PlanLoss } from "../emails/_components/plan-loss";import CancellationScheduledEmail from "../emails/cancellation-scheduled";import MemberRemovedEmail from "../emails/member-removed";import PlanDowngradedEmail, { planDowngradedSubject,} from "../emails/plan-downgraded";import PlanEndingSoonEmail, { planEndingSoonSubject,} from "../emails/plan-ending-soon";import TrialEndingEmail, { trialEndingSubject } from "../emails/trial-ending";import { billingRecipients, planLossLines, reminderScheduledAt, schedulePlanEndingSoon, sendCancellationScheduled, sendMemberRemoved, sendPlanDowngraded, sendTrialEnding,} from "./billing";import { cancelScheduledEmail, delivery, resend, sendEmail } from "./send";
const loss = { monitorsDeactivated: 11, pagesDeleted: ["Acme API", "Acme EU"], keptPageTitle: "Acme", notificationsDeleted: 3, invitationsDeleted: 1, membersRemoved: 4, customDomains: ["status.acme.dev"], sso: true,} satisfies PlanLoss;
const nothing = { monitorsDeactivated: 0, pagesDeleted: [], keptPageTitle: null, notificationsDeleted: 0, invitationsDeleted: 0, membersRemoved: 0, customDomains: [], sso: false,} satisfies PlanLoss;
const endsAt = new Date("2026-09-25T00:00:00Z");
describe("billing templates", () => { test("plan-downgraded names deleted pages and counts everything else", async () => { const props = { workspaceSlug: "acme-dev", previousPlan: "team", loss }; const html = await render(<PlanDowngradedEmail {...props} />); expect(html).toContain("ACCOUNT CHANGE"); expect(html).toContain("acme-dev"); expect(html).toContain("team โ free"); expect(html).toContain("Acme API, Acme EU"); expect(html).toContain("Deleted status pages cannot be restored"); expect(html).toContain("11 monitors, oldest stays active"); expect(html).toContain("4 members"); expect(html).toContain("status.acme.dev"); expect(html).toContain("Off, IdP configuration kept"); expect(html).toContain("workspace owners and the billing contact"); expect(html).toContain("Co-founder, openstatus"); expect(html).toContain( 'href="https://app.openstatus.dev/settings/billing"', ); expect(planDowngradedSubject(props)).toBe( "Your workspace is on the free plan โ 2 status pages deleted", ); });
test("plan-downgraded with nothing trimmed drops every loss row", async () => { const props = { workspaceSlug: "acme-dev", previousPlan: "starter", loss: nothing, }; const html = await render(<PlanDowngradedEmail {...props} />); expect(html).toContain("No status page was deleted"); expect(html).not.toContain("Status pages deleted"); expect(html).not.toContain("Members removed"); expect(html).not.toContain("SAML SSO"); expect(planDowngradedSubject(props)).toBe( "Your workspace is on the free plan โ here is what changed", ); });
test("plan-ending-soon is dated and in the future tense", async () => { const props = { workspaceSlug: "acme-dev", plan: "team", endsAt, loss }; const html = await render(<PlanEndingSoonEmail {...props} />); expect(html).toContain("ACTION NEEDED"); expect(html).toContain("Your team plan ends on Friday 25 September"); expect(html).toContain("Fri 25 Sep 2026"); expect(html).toContain("Status pages that get deleted"); expect(html).toContain("Resume subscription"); expect(planEndingSoonSubject(props)).toBe( "Your team plan ends on 25 Sep โ resume to keep everything", ); });
test("member-removed: one line, who to ask, no CTA", async () => { const html = await render( <MemberRemovedEmail workspaceName="Acme" reason="downgrade" owners={["max@acme.dev"]} />, ); expect(html).toContain("You were removed from Acme"); expect(html).toContain("subscription ended"); expect(html).toContain("max@acme.dev"); expect(html).not.toContain("app.openstatus.dev");
const manual = await render( <MemberRemovedEmail workspaceName="Acme" reason="manual" owners={[]} />, ); expect(manual).toContain("A workspace owner removed you"); expect(manual).not.toContain("Ask for access"); });
test("cancellation-scheduled is raw: no layout, date, losses, resume link", async () => { const html = await render( <CancellationScheduledEmail plan="team" endsAt={endsAt} losses={planLossLines(loss)} />, ); expect(html).toContain("Fri 25 Sep 2026"); expect(html).toContain("2 status pages get deleted (Acme API, Acme EU)"); expect(html).toContain("4 members lose access"); expect(html).toContain("what made you cancel?"); expect(html).toContain( 'href="https://app.openstatus.dev/settings/billing"', ); expect(html).not.toContain("max-width:600px");
const bare = await render( <CancellationScheduledEmail plan="team" endsAt={endsAt} losses={[]} />, ); expect(bare).not.toContain("moves to the free plan"); });
test("trial-ending without a card lists what gets removed", async () => { const props = { workspaceSlug: "acme-dev", plan: "starter", trialEnd: endsAt, hasPaymentMethod: false, loss, }; const html = await render(<TrialEndingEmail {...props} />); expect(trialEndingSubject(props)).toBe( "Your trial ends on 25 Sep โ add a payment method to keep starter", ); expect(html).toContain("ACTION NEEDED"); expect(html).toContain("Your trial ends on Friday 25 September"); expect(html).toContain("acme-dev"); expect(html).toContain("starter โ free"); expect(html).toContain("Acme API, Acme EU"); expect(html).toContain("Add payment method"); expect(html).not.toContain("on file, so"); });
test("trial-ending with a card says the plan continues", async () => { const props = { workspaceSlug: "acme-dev", plan: "starter", trialEnd: endsAt, hasPaymentMethod: true, loss, }; const html = await render(<TrialEndingEmail {...props} />); expect(trialEndingSubject(props)).toBe( "Your trial ends on 25 Sep โ your starter plan continues", ); expect(html).toContain("Nothing to do"); expect(html).toContain("starter, continues"); expect(html).toContain("Manage subscription"); expect(html).not.toContain("Acme API, Acme EU"); expect(html).not.toContain("Add payment method"); });
test("trial-ending without a card and nothing to trim", async () => { const html = await render( <TrialEndingEmail workspaceSlug="acme-dev" plan="starter" trialEnd={endsAt} hasPaymentMethod={false} loss={nothing} />, ); expect(html).toContain("free plan on that day."); expect(html).not.toContain("trimmed"); });
test("loss lines use singular forms", () => { expect( planLossLines({ ...nothing, pagesDeleted: ["A"], monitorsDeactivated: 1, membersRemoved: 1, }), ).toEqual([ "1 status page gets deleted (A)", "1 monitor gets paused", "1 member loses access", ]); });
test("loss lines cover every destructive change", () => { expect( planLossLines({ ...nothing, invitationsDeleted: 2, notificationsDeleted: 1, customDomains: ["a.dev", "b.dev"], sso: true, }), ).toEqual([ "2 pending invitations get deleted", "1 notification channel gets removed", "the custom domains a.dev, b.dev are released", "SAML SSO turns off", ]); });
test("custom domain label follows the count", async () => { const one = await render( <PlanDowngradedEmail workspaceSlug="a" previousPlan="team" loss={{ ...nothing, customDomains: ["a.dev"] }} />, ); expect(one).toContain("Custom domain released"); const two = await render( <PlanDowngradedEmail workspaceSlug="a" previousPlan="team" loss={{ ...nothing, customDomains: ["a.dev", "b.dev"] }} />, ); expect(two).toContain("Custom domains released"); });});
describe("billingRecipients", () => { test("owners + customer, case-insensitive dedupe, empties dropped", () => { expect( billingRecipients( ["Max@Acme.dev", "", null, " ", "thibault@acme.dev"], "max@acme.dev", ), ).toEqual(["Max@Acme.dev", "thibault@acme.dev"]); expect(billingRecipients([], "billing@acme.dev")).toEqual([ "billing@acme.dev", ]); expect(billingRecipients([undefined], null)).toEqual([]); });});
describe("reminderScheduledAt", () => { const now = new Date("2026-09-01T00:00:00Z");
test("period end minus 3 days", () => { expect(reminderScheduledAt(endsAt, now)?.toISOString()).toBe( "2026-09-22T00:00:00.000Z", ); });
test("null inside the lead window and beyond Resend's 30 days", () => { expect(reminderScheduledAt(new Date("2026-09-03T00:00:00Z"), now)).toBe( null, ); expect(reminderScheduledAt(new Date("2027-03-01T00:00:00Z"), now)).toBe( null, ); });});
describe("send plumbing", () => { let send: Stub, batch: Stub, cancel: Stub, enabled: Stub; // safe because the verbs only read `data.id` and `error.name` const result = (value: unknown) => Promise.resolve(value as never); const ok = { data: { id: "email_123" }, error: null };
beforeEach(() => { enabled = stub(delivery, "enabled", () => true); send = stub(resend.emails, "send", () => result(ok)); batch = stub(resend.batch, "send", () => result(ok)); cancel = stub(resend.emails, "cancel", () => result(ok)); });
afterEach(() => { for (const s of [enabled, send, batch, cancel]) s.restore(); });
const email = { from: "a <a@openstatus.dev>", to: ["b@example.com"], subject: "s", react: ( <TrialEndingEmail workspaceSlug="acme-dev" plan="starter" trialEnd={endsAt} hasPaymentMethod={false} /> ), };
test("sendEmail forwards idempotencyKey and scheduledAt, returns the id", async () => { const id = await sendEmail( { ...email, reply_to: "r@openstatus.dev" }, { idempotencyKey: "k", scheduledAt: "2026-09-22T00:00:00.000Z" }, ); expect(id).toBe("email_123"); expect(send.calls[0].args[0].scheduledAt).toBe("2026-09-22T00:00:00.000Z"); expect(send.calls[0].args[0].replyTo).toBe("r@openstatus.dev"); expect(send.calls[0].args[1]).toEqual({ idempotencyKey: "k" }); });
test("sendEmail omits both when not given", async () => { await sendEmail(email); expect("scheduledAt" in send.calls[0].args[0]).toBe(false); expect(send.calls[0].args[1]).toBeUndefined(); });
test("sendEmail does nothing outside production", async () => { enabled.restore(); enabled = stub(delivery, "enabled", () => false); expect(await sendEmail(email)).toBeUndefined(); assertSpyCalls(send, 0); });
test("an idempotency conflict or Resend error never throws", async () => { for (const name of ["invalid_idempotent_request", "application_error"]) { send.restore(); send = stub(resend.emails, "send", () => result({ data: null, error: { name } }), ); expect(await sendEmail(email, { idempotencyKey: "k" })).toBeUndefined(); assertSpyCalls(send, 1); } });
test("cancelScheduledEmail wraps emails.cancel and reports success", async () => { expect(await cancelScheduledEmail("email_123")).toBe(true); expect(cancel.calls[0].args[0]).toBe("email_123");
cancel.restore(); cancel = stub(resend.emails, "cancel", () => result({ data: null, error: { name: "application_error" } }), ); expect(await cancelScheduledEmail("email_123")).toBe(false); });
test("sendMemberRemoved chunks at Resend's 100-email batch limit", async () => { await sendMemberRemoved({ to: Array.from({ length: 250 }, (_, i) => `m${i}@acme.dev`), idempotencyKey: "stripe:evt_1:member-removed", workspaceName: "Acme", reason: "downgrade", owners: [], }); assertSpyCalls(batch, 3); expect(batch.calls.map((c) => c.args[0].length)).toEqual([100, 100, 50]); expect(batch.calls.map((c) => c.args[1].idempotencyKey)).toEqual([ "stripe:evt_1:member-removed:0", "stripe:evt_1:member-removed:1", "stripe:evt_1:member-removed:2", ]); });
test("a batch error is swallowed, not thrown", async () => { batch.restore(); batch = stub(resend.batch, "send", () => result({ data: null, error: { name: "application_error" } }), ); await sendMemberRemoved({ to: ["a@acme.dev"], workspaceName: "Acme", reason: "downgrade", owners: [], }); assertSpyCalls(batch, 1); });
test("sendPlanDowngraded", async () => { await sendPlanDowngraded({ to: ["max@acme.dev"], eventId: "evt_1", workspaceSlug: "acme-dev", previousPlan: "team", loss, }); const [payload, options] = send.calls[0].args; expect(payload.to).toEqual(["max@acme.dev"]); expect(payload.from).toBe( "Thibault from openstatus <thibault@notifications.openstatus.dev>", ); expect(payload.replyTo).toBe("thibault@openstatus.dev"); expect(payload.subject).toBe( "Your workspace is on the free plan โ 2 status pages deleted", ); expect(options).toEqual({ idempotencyKey: "stripe:evt_1:plan-downgraded" }); });
test("sendMemberRemoved batches one mail per member under one key", async () => { await sendMemberRemoved({ to: ["a@acme.dev", "b@acme.dev"], idempotencyKey: "stripe:evt_1:member-removed", workspaceName: "Acme", reason: "downgrade", owners: ["max@acme.dev"], }); const [emails, options] = batch.calls[0].args; expect(emails.map((e: { to: string }) => e.to)).toEqual([ "a@acme.dev", "b@acme.dev", ]); expect(emails[0].from).toBe( "openstatus <notifications@notifications.openstatus.dev>", ); expect(emails[0].replyTo).toBe("ping@openstatus.dev"); expect(emails[0].subject).toBe( "You no longer have access to Acme on openstatus", ); expect(emails[0].html).toContain("max@acme.dev"); expect(options).toEqual({ idempotencyKey: "stripe:evt_1:member-removed:0", }); });
test("sendCancellationScheduled is raw, from the root domain", async () => { await sendCancellationScheduled({ to: ["max@acme.dev"], eventId: "evt_2", plan: "team", endsAt, loss, }); const [payload, options] = send.calls[0].args; expect(payload.from).toBe( "Thibault from openstatus <thibault@openstatus.dev>", ); expect(payload.subject).toBe("Your openstatus cancellation"); expect(options).toEqual({ idempotencyKey: "stripe:evt_2:cancellation-scheduled", }); });
test("schedulePlanEndingSoon schedules 3 days before and returns the id", async () => { const id = await schedulePlanEndingSoon({ to: ["max@acme.dev"], eventId: "evt_2", workspaceSlug: "acme-dev", plan: "team", endsAt, loss, now: new Date("2026-09-01T00:00:00Z"), }); expect(id).toBe("email_123"); const [payload, options] = send.calls[0].args; expect(payload.scheduledAt).toBe("2026-09-22T00:00:00.000Z"); expect(options).toEqual({ idempotencyKey: "stripe:evt_2:plan-ending-soon", }); });
test("schedulePlanEndingSoon skips a period end beyond 30 days", async () => { const id = await schedulePlanEndingSoon({ to: ["max@acme.dev"], eventId: "evt_2", workspaceSlug: "acme-dev", plan: "team", endsAt: new Date("2027-03-01T00:00:00Z"), loss, now: new Date("2026-09-01T00:00:00Z"), }); expect(id).toBeUndefined(); assertSpyCalls(send, 0); });
test("sendTrialEnding", async () => { await sendTrialEnding({ to: ["max@acme.dev"], eventId: "evt_3", workspaceSlug: "acme-dev", trialEnd: endsAt, plan: "starter", hasPaymentMethod: false, loss, }); const [payload, options] = send.calls[0].args; expect(payload.from).toBe( "Thibault from openstatus <thibault@notifications.openstatus.dev>", ); expect(payload.replyTo).toBe("thibault@openstatus.dev"); expect(payload.subject).toBe( "Your trial ends on 25 Sep โ add a payment method to keep starter", ); expect(options).toEqual({ idempotencyKey: "stripe:evt_3:trial-ending" }); });
test("no verb sends without recipients", async () => { await sendPlanDowngraded({ to: [], eventId: "e", workspaceSlug: "a", previousPlan: "team", loss, }); await sendMemberRemoved({ to: [], workspaceName: "A", reason: "downgrade", owners: [], }); await sendTrialEnding({ to: [], eventId: "e", workspaceSlug: "a", trialEnd: endsAt, plan: "starter", hasPaymentMethod: false, }); assertSpyCalls(send, 0); assertSpyCalls(batch, 0); });});