forked from Mike/freno-dev
nook: capture trial email and run the two lifecycle emails
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
This commit is contained in:
77
src/routes/api/the-nook/trial-email.test.ts
Normal file
77
src/routes/api/the-nook/trial-email.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user