forked from Mike/freno-dev
Trials were anonymous — a 14-day trial you cannot talk to. trials gains an email column (idempotent ALTER), POST /api/the-nook/trial-email attaches the optional first-launch prompt's answer, and the address lands in a Brevo list when NOOK_TRIAL_LIST_ID is set. The lifecycle emails follow: a D+11 trial expiry reminder with a buy link, a purchase thank-you from the Stripe webhook, and scheduled testimonial (D+3) and review (D+14) asks via the daily /api/the-nook/lifecycle cron. Rows are marked before sending so retries never double-send; trial addresses that already bought get no buy reminder; the endpoint requires a Bearer secret (CRON_SECRET / NOOK_LIFECYCLE_SECRET) and fails closed when unconfigured — an open trigger would email real users. All sends are best-effort and logged, never thrown. Tests: trial-email (3) and lifecycle (6) suites; full run 539 pass. Issues: FRE-10, FRE-11
78 lines
2.6 KiB
TypeScript
78 lines
2.6 KiB
TypeScript
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
|
|
|
// The route's contracts:
|
|
// 1. Both fingerprint (UUID) and email are validated — bad input never
|
|
// reaches the store.
|
|
// 2. The email is normalized (trimmed, lowercased) before storage.
|
|
// 3. The response is `{ success: true }` regardless of whether a trial row
|
|
// already existed — the client learns nothing it doesn't know.
|
|
// NookConnectionFactory and the nook helper module are mocked so the test
|
|
// needs no real DB, env, or outbound email.
|
|
|
|
const UUID = "a1b2c3d4-e5f6-4a1b-8c2d-1234567890ab";
|
|
const calls: { sql: string; args?: unknown[] }[] = [];
|
|
|
|
const fakeConn = {
|
|
execute: async (q: { sql: string; args?: unknown[] }) => {
|
|
calls.push(q);
|
|
return { rows: [], rowsAffected: 1 };
|
|
}
|
|
};
|
|
|
|
mock.module("~/server/db-connections", () => ({
|
|
NookConnectionFactory: () => fakeConn
|
|
}));
|
|
|
|
mock.module("~/server/nook", () => ({
|
|
// Full export surface: bun's mock registry is shared across test files in
|
|
// one run, and a partial mock here would starve resend-license.test.ts.
|
|
nookSchemaBootstrap: Promise.resolve(),
|
|
emailLicenseKey: async () => {},
|
|
issueLicense: async () => ({ key: "", id: "" }),
|
|
grantLicense: async () => ({ key: "", id: "" }),
|
|
setTrialEmail: async (fingerprint: string, email: string) => {
|
|
calls.push({ sql: "setTrialEmail", args: [fingerprint, email] });
|
|
return true;
|
|
},
|
|
verifyLicenseKey: () => true
|
|
}));
|
|
|
|
const { POST } = await import("./trial-email");
|
|
|
|
function request(body: unknown): any {
|
|
return { request: { json: async () => body } };
|
|
}
|
|
|
|
describe("POST /api/the-nook/trial-email", () => {
|
|
beforeEach(() => {
|
|
calls.length = 0;
|
|
});
|
|
|
|
it("rejects an invalid fingerprint", async () => {
|
|
const res = await POST(
|
|
request({ fingerprint: "nope", email: "a@b.co" })
|
|
);
|
|
expect(res.status).toBe(400);
|
|
expect(await res.json()).toEqual({ error: "Invalid fingerprint" });
|
|
});
|
|
|
|
it("rejects an invalid email", async () => {
|
|
const res = await POST(
|
|
request({ fingerprint: UUID, email: "not-an-email" })
|
|
);
|
|
expect(res.status).toBe(400);
|
|
expect(await res.json()).toEqual({ error: "Invalid email" });
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it("normalizes the email and stores it against the fingerprint", async () => {
|
|
const res = await POST(
|
|
request({ fingerprint: UUID, email: " USER@Example.COM " })
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(await res.json()).toEqual({ success: true });
|
|
const stored = calls.find((c) => c.sql === "setTrialEmail");
|
|
expect(stored?.args).toEqual([UUID, "user@example.com"]);
|
|
});
|
|
});
|