diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts index c90809eb..fa512c8c 100644 --- a/lib/ads/serve.ts +++ b/lib/ads/serve.ts @@ -151,16 +151,20 @@ export async function serveAd( Array.isArray(c) ? c[0] : c; const today = new Date().toISOString().slice(0, 10); // UTC yyyy-mm-dd - // Budget pacing: keep only campaigns with room for at least one more click at - // their bid today. spend_today resets implicitly when spend_date is earlier. - // Never serve someone their own ad on their own slot. ad_charge_click refuses - // to bill or accrue on a self-click anyway; filtering here means we don't burn - // an impression and a redirect on a click that can't earn. - const candidates = (creatives as unknown as Row[]).filter((row) => { - const c = oneCampaign(row.ad_campaigns); - if (!c) return false; - return !(slot.owner_id && c.owner_id === slot.owner_id); - }); + // A self-owned campaign (same profile owns the slot and the campaign) can + // never earn: ad_charge_click refuses to bill or accrue on a self-click. It + // used to be dropped here outright, which is correct on a network with other + // advertisers and catastrophic on one without — while every slot and every + // campaign belong to the same account, that filter removed 100% of inventory + // and every request fell through to the house ad. Serving stopped entirely + // and nothing recorded an impression, because house fills aren't metered. + // + // So self-owned campaigns are demoted rather than discarded, below. Between a + // real advertiser's creative and the house ad — neither of which can earn on + // this request — the real creative is strictly the better fill. + const candidates = (creatives as unknown as Row[]).filter( + (row) => !!oneCampaign(row.ad_campaigns), + ); if (candidates.length === 0) return houseFill(format); // A campaign is PAID-eligible only if its owner can actually cover a click at @@ -187,9 +191,14 @@ export async function serveAd( const spentToday = c.spend_date === today ? c.spend_today_cents : 0; const hasBudget = spentToday + bid * CREDIT_CENTS <= c.daily_budget_cents; const hasFunds = (creditsByOwner.get(c.owner_id) ?? 0) >= bid; + // Same owner on both sides of the transaction: the click can't be billed, + // so it must never win paid inventory ahead of an advertiser who would + // actually pay. Free tier is exactly the right home for it — same place a + // campaign that has run out of funds goes. + const isSelfDeal = !!(slot.owner_id && c.owner_id === slot.owner_id); // Legacy 'exhausted' rows never compete for paid inventory on that status // alone — funds decide, and a top-up puts them straight back in the auction. - (hasBudget && hasFunds ? paid : free).push(row); + (hasBudget && hasFunds && !isSelfDeal ? paid : free).push(row); } // Paid inventory first, always. Free-tier campaigns only ever fill requests no diff --git a/tests/contract/ads-self-deal.test.ts b/tests/contract/ads-self-deal.test.ts new file mode 100644 index 00000000..11601c15 --- /dev/null +++ b/tests/contract/ads-self-deal.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Regression: a self-owned campaign (same profile owns the slot and the +// campaign) used to be filtered out of the candidate list entirely. That is +// right on a network with other advertisers, and fatal on one without — while +// every slot and campaign belonged to a single account it removed 100% of +// inventory, every request fell through to the house ad, and because house +// fills are unmetered, impressions stopped being recorded at all. +// +// The rule that must survive: a self-deal can never win PAID inventory ahead of +// an advertiser who would actually pay. The rule that was wrong: dropping it. + +// Declared inside vi.hoisted: the mock factory below is hoisted above ordinary +// const declarations, so it cannot close over them. +const H = vi.hoisted(() => { + const OWNER = "11111111-1111-1111-1111-111111111111"; + const OTHER = "22222222-2222-2222-2222-222222222222"; + return { + OWNER, + OTHER, + state: { + slotOwner: OWNER as string | null, + campaignOwners: [OWNER] as string[], + credits: 9999, + inserted: [] as Record[], + }, + }; +}); +const { OWNER, OTHER, state } = H; + +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; +} + +function creativeFor(ownerId: string, i: number) { + return { + id: `cre-${i}`, + campaign_id: `camp-${i}`, + format: "terminal_ascii", + headline: `Advertiser ${i}`, + body: "Real advertiser copy.", + cta_text: "Go", + image_url: null, + logo_url: null, + bg_color: "#0b0d10", + fg_color: "#e7e9ee", + accent_color: "#6ee7b7", + font_family: "system-ui", + ad_campaigns: { + id: `camp-${i}`, + owner_id: ownerId, + status: "active", + ref_slug: `adv-${i}`, + destination_url: "https://advertiser.example/", + daily_budget_cents: 500, + spend_today_cents: 0, + spend_date: null, + bid_credits: 4, + }, + }; +} + +vi.mock("@/lib/supabase/service", () => ({ + serviceClient: () => ({ + from(table: string) { + if (table === "ad_slots") { + return chain({ + data: { + id: "slot-1", + status: "active", + formats: ["terminal_ascii"], + owner_id: state.slotOwner, + }, + error: null, + }); + } + if (table === "ad_creatives") { + return chain({ + data: state.campaignOwners.map((o, i) => creativeFor(o, i)), + error: null, + }); + } + if (table === "profiles") { + return chain({ + data: [...new Set(state.campaignOwners)].map((id) => ({ + id, + credits_balance: state.credits, + ad_bonus_credits: 0, + })), + error: null, + }); + } + if (table === "ad_impressions") { + return { + insert(payload: Record) { + state.inserted.push(payload); + return chain({ data: { id: "imp-1", ...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" }); +} + +/** Fill repeatedly, since ~10% of paid-eligible fills are house by design. */ +async function fills(n: number) { + const out = []; + for (let i = 0; i < n; i++) out.push(await serve()); + return out; +} + +describe("self-owned campaigns on a single-tenant network", () => { + beforeEach(() => { + vi.resetModules(); + state.slotOwner = OWNER; + state.campaignOwners = [OWNER, OWNER, OWNER]; + state.credits = 9999; + state.inserted = []; + }); + + it("still serves real creatives when the only campaigns are self-owned", async () => { + const served = await fills(40); + const real = served.filter((f) => f && f.campaignId !== "house"); + // The blackout: every one of these came back as the house ad. + expect(real.length).toBeGreaterThan(0); + expect(real[0]!.creative.headline).toMatch(/^Advertiser /); + }); + + it("records impressions again, so the dashboard is not blank", async () => { + await fills(40); + expect(state.inserted.length).toBeGreaterThan(0); + }); + + it("meters a self-deal as free, never as paid", async () => { + await fills(40); + const tiers = new Set(state.inserted.map((r) => r.tier)); + expect(tiers).toEqual(new Set(["free"])); + }); +}); + +describe("self-deal still loses to a real advertiser", () => { + beforeEach(() => { + vi.resetModules(); + state.slotOwner = OWNER; + state.credits = 9999; + state.inserted = []; + }); + + it("never gives paid inventory to the slot owner's own campaign", async () => { + // One self-owned campaign, one genuine third-party advertiser. + state.campaignOwners = [OWNER, OTHER]; + await fills(60); + const paid = state.inserted.filter((r) => r.tier === "paid"); + expect(paid.length).toBeGreaterThan(0); + // camp-0 is the self-owned one; it must never be billed against this slot. + for (const row of paid) expect(row.campaign_id).toBe("camp-1"); + }); + + it("puts a third-party advertiser on the paid tier", async () => { + state.campaignOwners = [OTHER]; + await fills(40); + expect(state.inserted.some((r) => r.tier === "paid")).toBe(true); + }); + + it("keeps a slot with no owner working", async () => { + state.slotOwner = null; + state.campaignOwners = [OWNER]; + await fills(40); + expect(state.inserted.some((r) => r.tier === "paid")).toBe(true); + }); +});