Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/web/lib/entitlements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,23 @@ describe("recordPromoRedemption (atomic duplicate guard, #92)", () => {
expect(ok).toBe(false);
expect(execute).not.toHaveBeenCalled();
});

// The grant (and its one-time API key) is already issued by the time we get
// here, so a DB blip must not escape and 500 the caller's response.
it("swallows a failing INSERT and returns false", async () => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
execute.mockRejectedValueOnce(new Error("db unreachable"));
await expect(recordPromoRedemption("SUMMER25", "user_1")).resolves.toBe(false);
expect(err).toHaveBeenCalled();
err.mockRestore();
});

it("swallows a failing uses increment but still reports the recorded row", async () => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
execute.mockResolvedValueOnce({ rows: [], rowsAffected: 1 }); // INSERT OR IGNORE landed
execute.mockRejectedValueOnce(new Error("db unreachable")); // UPDATE uses blew up
await expect(recordPromoRedemption("SUMMER25", "user_1")).resolves.toBe(true);
expect(err).toHaveBeenCalled();
err.mockRestore();
});
});
35 changes: 24 additions & 11 deletions apps/web/lib/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,21 +148,34 @@ export async function quotePromo(
* single atomic statement instead of the separate check-then-record pair in
* quotePromo()/recordPromoRedemption() (fixes #92, TOCTOU). The composite PK
* (code, user_id) makes the INSERT itself the source of truth.
*
* Never throws. Callers grant the entitlement BEFORE recording the redemption,
* and the grant response carries the one-time API key plaintext — letting a DB
* blip escape here would 500 that response and lose the key for good. Log and
* report "not newly recorded" instead.
*/
export async function recordPromoRedemption(codeRaw: string, userId: string): Promise<boolean> {
const code = codeRaw.trim().toUpperCase();
if (!code) return false;
const res = await sqlClient.execute({
sql: "INSERT OR IGNORE INTO promo_redemptions (code, user_id) VALUES (?, ?)",
args: [code, userId],
});
if (Number(res.rowsAffected) !== 1) return false; // already recorded
// Atomic capped increment (also covered by #106): never over-redeem past max_uses.
await sqlClient.execute({
sql: "UPDATE promo_codes SET uses = uses + 1 WHERE code = ? AND (max_uses IS NULL OR uses < max_uses)",
args: [code],
});
return true;
// Tracked outside the try so a failed counter bump still reports the
// redemption that did land, rather than claiming it never happened.
let recorded = false;
try {
const res = await sqlClient.execute({
sql: "INSERT OR IGNORE INTO promo_redemptions (code, user_id) VALUES (?, ?)",
args: [code, userId],
});
if (Number(res.rowsAffected) !== 1) return false; // already recorded
recorded = true;
// Atomic capped increment (also covered by #106): never over-redeem past max_uses.
await sqlClient.execute({
sql: "UPDATE promo_codes SET uses = uses + 1 WHERE code = ? AND (max_uses IS NULL OR uses < max_uses)",
args: [code],
});
} catch (err) {
console.error(`[promo] failed to record redemption of ${code}:`, (err as Error).message);
}
return recorded;
}

/** Apply the entitlement for a purpose directly (used for 100%-off comps). */
Expand Down
Loading