From 95c61eaaafb7ade75f427b4fa53c0b1211e61eba Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 15:09:27 +0000 Subject: [PATCH] test(ads): cover the house rotation rate in serveAd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #183 de-flaked tests/contract/ads-short-code-serving.test.ts by mocking HOUSE_AD_ROTATION_RATE to 0 for that file, which was the right call — the tests there are about the click URL of a paid fill, and the coin flip made roughly one CI run in three go red on whichever test lost it. That commit notes rotation is still covered by ads-house-rotation.test.ts. It isn't, quite: that file calls houseFill() directly and never goes through serveAd, so it pins what a house ad looks like, not when one is served. With the rate pinned to 0 in the only file that reached it, the branch at lib/ads/serve.ts:213 has no test left. That branch is what keeps the network advertising itself on slots that are already selling, and it is one comparison — the kind of thing that can be deleted or inverted in a refactor and show up as a revenue question months later, not a red build. So it is pinned here against serveAd, by driving the draw to each side of the threshold rather than sampling a ~10% frequency. Sampling would mean reintroducing exactly the coin flip that made the other file flaky; a controlled draw tests the same behaviour and cannot fail intermittently. Verified by mutation: '<' to '<=' fails 1 test, inverting the comparison fails 3, deleting the branch fails 2. Co-Authored-By: Claude Opus 5 (1M context) --- .../contract/ads-house-rotation-rate.test.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/contract/ads-house-rotation-rate.test.ts diff --git a/tests/contract/ads-house-rotation-rate.test.ts b/tests/contract/ads-house-rotation-rate.test.ts new file mode 100644 index 00000000..55efc6c2 --- /dev/null +++ b/tests/contract/ads-house-rotation-rate.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HOUSE_AD_ROTATION_RATE } from "@/lib/ads/house"; + +// serveAd hands HOUSE_AD_ROTATION_RATE of otherwise-fillable requests to the +// house ad, so the network keeps promoting itself on slots that are already +// selling. That branch is the only thing standing between "we advertise +// ourselves" and "we silently stopped", and it is easy to lose: it is one +// comparison, and the file that used to exercise it now pins the rate to 0 to +// stay deterministic (tests/contract/ads-short-code-serving.test.ts). +// +// ads-house-rotation.test.ts does not cover it either — that file calls +// houseFill() directly and never goes through serveAd, so it pins what a house +// ad *looks like*, not when one is served. +// +// So the rate is pinned here, against serveAd, by controlling the draw rather +// than sampling it. Asserting a ~10% frequency over N fills would mean +// reintroducing exactly the coin flip that made the other file flaky; driving +// the draw to each side of the threshold tests the same behaviour and cannot +// fail intermittently. + +const state = vi.hoisted(() => ({ inserts: [] as Record[] })); + +const SLOT = { + id: "slot-1", + status: "active", + formats: ["terminal_ascii"], + owner_id: "pub-1", +}; + +// One funded, in-budget campaign, so serveAd has paid inventory and therefore +// reaches the rotation branch at all. With an empty auction it would fall +// through to the free/house backfill for unrelated reasons. +const CREATIVE = { + id: "cre-1", + campaign_id: "camp-1", + format: "terminal_ascii", + headline: "Ship faster", + body: "One command.", + cta_text: "Try it", + image_url: null, + logo_url: null, + bg_color: "#0b0d10", + fg_color: "#e7e9ee", + accent_color: "#6ee7b7", + font_family: "system-ui", + ad_campaigns: { + id: "camp-1", + owner_id: "adv-1", + status: "active", + ref_slug: "acme", + destination_url: "https://advertiser.example/", + daily_budget_cents: 500, + spend_today_cents: 0, + spend_date: null, + bid_credits: 4, + }, +}; + +const OWNER = { id: "adv-1", credits_balance: 1000, ad_bonus_credits: 0 }; + +/** A thenable stub: every builder method chains, awaiting yields `result`. */ +function chain(result: unknown): unknown { + const c: unknown = new Proxy( + {}, + { + get(_t, prop) { + if (prop === "then") { + return (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) => + Promise.resolve(result).then(res, rej); + } + return () => c; + }, + }, + ); + return c; +} + +vi.mock("@/lib/supabase/service", () => ({ + serviceClient: () => ({ + from(table: string) { + if (table === "ad_slots") return chain({ data: SLOT, error: null }); + if (table === "ad_creatives") return chain({ data: [CREATIVE], error: null }); + if (table === "profiles") return chain({ data: [OWNER], error: null }); + if (table === "ad_impressions") { + return { + insert(payload: Record) { + state.inserts.push(payload); + return chain({ + data: { id: "11111111-2222-3333-4444-555555555555", ...payload }, + error: null, + }); + }, + }; + } + return chain({ data: null, error: null }); + }, + }), +})); + +async function serve() { + const { serveAd } = await import("@/lib/ads/serve"); + return serveAd("slot-1", "terminal_ascii", { device: "terminal", src: "motd" }); +} + +describe("house ad rotation rate in serveAd", () => { + beforeEach(() => { + vi.resetModules(); + state.inserts = []; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("gives the house ad the fills whose draw falls under the rate", async () => { + vi.spyOn(Math, "random").mockReturnValue(HOUSE_AD_ROTATION_RATE / 2); + const fill = await serve(); + expect(fill?.campaignId).toBe("house"); + }); + + it("does not meter a house fill", async () => { + vi.spyOn(Math, "random").mockReturnValue(0); + await serve(); + // No impression row: nothing exists for a click to charge against, which is + // what "unmetered" has to mean in practice. A house ad that wrote an + // impression would bill a publisher for the network's own promotion. + expect(state.inserts).toHaveLength(0); + }); + + it("keeps the fill paid when the draw lands exactly on the rate", async () => { + // The comparison is `<`, so the rate itself is not a house fill. Pinning the + // boundary means a slip to `<=` fails here, rather than surviving as a + // rotation that is fractionally off and that nobody would ever notice. + vi.spyOn(Math, "random").mockReturnValue(HOUSE_AD_ROTATION_RATE); + const fill = await serve(); + expect(fill?.campaignId).toBe("camp-1"); + expect(state.inserts).toHaveLength(1); + }); + + it("serves paid inventory on the great majority of fills", async () => { + // Guards the direction of the comparison as well as its boundary: were the + // branch inverted, the rate would still be "applied" and both tests above + // would still pass, but ~90% of fills would go unpaid. + vi.spyOn(Math, "random").mockReturnValue(0.9); + expect((await serve())?.campaignId).toBe("camp-1"); + }); +});