diff --git a/app/a/[id]/route.ts b/app/a/[id]/route.ts index 0d6195e6..697ae313 100644 --- a/app/a/[id]/route.ts +++ b/app/a/[id]/route.ts @@ -14,6 +14,7 @@ import { resolveClick } from "@/lib/ads/serve"; import { serviceClient } from "@/lib/supabase/service"; import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo"; import { parseDevice } from "@/lib/tracker/device"; +import { isShortCode } from "@/lib/ads/shortcode"; import { env } from "@/lib/env"; export const runtime = "nodejs"; @@ -21,18 +22,62 @@ export const dynamic = "force-dynamic"; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +type ImpressionRow = { + id: string; + slot_id: string; + campaign_id: string; + creative_id: string; + visitor_id: string | null; + src?: string | null; +}; + +const BASE_COLS = "id, slot_id, campaign_id, creative_id, visitor_id"; + +/** + * Look up the impression by short code or UUID. + * + * `src` is a newer column and migrations here are applied by hand, so the app + * can briefly run ahead of the schema. Ask for it, and if the projection fails + * because the column isn't there yet, retry without it rather than dropping the + * click — an unresolved click is a payout the publisher never sees. Only the + * UUID path is worth retrying: a lookup *by* short code cannot succeed before + * the migration anyway. + */ +async function findImpression( + sb: ReturnType, + id: string, + byCode: boolean, +): Promise { + const column = byCode ? "short_code" : "id"; + const { data } = await sb + .from("ad_impressions") + .select(`${BASE_COLS}, src`) + .eq(column, id) + .maybeSingle(); + if (data) return data as ImpressionRow; + if (byCode) return null; + + const { data: legacy } = await sb + .from("ad_impressions") + .select(BASE_COLS) + .eq(column, id) + .maybeSingle(); + return (legacy as ImpressionRow) ?? null; +} + export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) { const fallback = env.siteUrl || "https://crawlproof.com"; try { const { id } = await ctx.params; - if (!UUID.test(id)) return NextResponse.redirect(fallback, { status: 302 }); + // Two address forms. New fills use a 12-character short code, which is what + // lets the URL fit inside a 44-col ASCII box. UUIDs are still accepted and + // must stay that way: click URLs from before the change are sitting in + // people's MOTDs, SSH banners and BBS screens, and those are not reissued. + const byCode = isShortCode(id); + if (!byCode && !UUID.test(id)) return NextResponse.redirect(fallback, { status: 302 }); const sb = serviceClient(); - const { data: imp } = await sb - .from("ad_impressions") - .select("id, slot_id, campaign_id, creative_id, visitor_id") - .eq("id", id) - .maybeSingle(); + const imp = await findImpression(sb, id, byCode); if (!imp) return NextResponse.redirect(fallback, { status: 302 }); const ip = clientIpFromHeaders(request.headers); @@ -63,8 +108,10 @@ export async function GET(request: NextRequest, ctx: { params: Promise<{ id: str // ?ref=; add utm on top, plus the publisher's own ?src= // surface tag when the ad carried one. Never overwrite utm params the // advertiser put on their own destination URL. + // Prefer the tag recorded on the impression; fall back to the query string + // for the older URLs that still carry "&s=" inline. const q = new URL(request.url).searchParams; - const src = q.get("s") ?? q.get("src"); + const src = imp.src ?? q.get("s") ?? q.get("src"); return NextResponse.redirect(withTerminalUtm(dest, src), { status: 302 }); } catch { return NextResponse.redirect(fallback, { status: 302 }); diff --git a/app/api/ads/motd/route.ts b/app/api/ads/motd/route.ts index 300ae496..44112369 100644 --- a/app/api/ads/motd/route.ts +++ b/app/api/ads/motd/route.ts @@ -95,6 +95,9 @@ export async function GET(request: NextRequest) { ip, country: geo?.countryCode ?? null, device, + // Recorded on the impression, so the printed click URL doesn't have to + // carry it. /a/ reads it back when it builds utm_content. + src: src || null, }); } // No slot given, or the slot is inactive / has no terminal inventory. @@ -103,13 +106,18 @@ export async function GET(request: NextRequest) { // Re-render at the caller's width/colour from the same creative + click URL // the fill was metered with. House fills keep their own border label so an // unsold slot doesn't read as a paid placement. - // Short key: the click URL is printed as literal text, so every character - // spent here is a character of box width. - const clickUrl = src ? withParam(fill.clickUrl, "s", src) : fill.clickUrl; + // + // The click URL is printed as literal text, so every character here is a + // character of box width. A paid fill already carries the surface tag on + // its impression row, so nothing is appended — that's what keeps /a/ + // inside a 44-col box. House fills have no impression row, so theirs still + // rides the URL, where /h has the room for it. + const isHouse = fill.campaignId === "house"; + const clickUrl = src && isHouse ? withParam(fill.clickUrl, "s", src) : fill.clickUrl; const body = renderCreativeText(fill.creative, clickUrl, { cols, color, - label: fill.campaignId === "house" ? "CRAWLPROOF ADS" : undefined, + label: isHouse ? "CRAWLPROOF ADS" : undefined, }); return new NextResponse(`${body}\n`, { status: 200, headers: h }); } catch { diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts index cebdf28b..c90809eb 100644 --- a/lib/ads/serve.ts +++ b/lib/ads/serve.ts @@ -14,6 +14,7 @@ import { houseFill, HOUSE_AD_ROTATION_RATE } from "./house"; import { CREDIT_CENTS, DEFAULT_BID_CREDITS, PLATFORM_RATE } from "./pricing"; import { assessClickValidity, isBotDevice } from "./fraud"; import { runAuction } from "./auction"; +import { generateShortCode } from "./shortcode"; // Server-side ad selection + metering. Runs under the service-role client so // the public serving endpoints can read cross-tenant campaigns/creatives and @@ -89,6 +90,13 @@ export type ServeContext = { ip?: string | null; country?: string | null; device?: string | null; + /** + * Publisher's surface tag (?src=bbs, ?src=ssh-banner, …). Recorded on the + * impression rather than appended to the printed click URL, where it cost up + * to 35 columns of a box that has 40. The click handler reads it back off the + * row to build utm_content. + */ + src?: string | null; }; // Returns a rendered fill for the slot, or null if the slot is inactive / @@ -218,32 +226,51 @@ export async function serveAd( if (!campaign) return null; // Record the impression first so we have an id to bind the click to. - const { data: imp } = await sb + const base = { + slot_id: slotId, + campaign_id: campaign.id, + creative_id: pick.id, + visitor_id: ctx.visitorId ?? null, + ip_hash: hashIp(ctx.ip ?? null), + geo_country: ctx.country ?? null, + device: ctx.device ?? null, + billable: false, + tier, + }; + + // The short code is what lets a terminal click URL fit inside the box, and + // ctx.src records the publisher's surface tag on the row instead of in the + // printed URL. Both live behind `add column if not exists`, and migrations + // here are applied by hand — so if this deploy lands first, the insert would + // fail on the unknown columns and take *all* paid serving down with it. + // Retry once without them and fall back to the UUID click URL: a wide URL is + // a cosmetic problem, a dropped impression is a lost sale. + const shortCode = generateShortCode(); + let { data: imp } = await sb .from("ad_impressions") - .insert({ - slot_id: slotId, - campaign_id: campaign.id, - creative_id: pick.id, - visitor_id: ctx.visitorId ?? null, - ip_hash: hashIp(ctx.ip ?? null), - geo_country: ctx.country ?? null, - device: ctx.device ?? null, - billable: false, - tier, - }) - .select("id") + .insert({ ...base, short_code: shortCode, src: ctx.src ?? null }) + .select("id, short_code") .single(); + if (!imp) { + ({ data: imp } = await sb.from("ad_impressions").insert(base).select("id").single()); + } + const impressionId = imp?.id ?? crypto.randomUUID(); + // Only address the click by code once we know the code was actually stored — + // otherwise /a/ would resolve to nothing and the click would go + // unmetered and unpaid. + const clickRef = + imp && "short_code" in imp && imp.short_code ? (imp.short_code as string) : impressionId; const creative = rowToCreative(pick); // Click goes through our redirector so we can meter it, then lands on the // destination with ?ref= applied. Terminals print the URL as literal text, so - // the terminal format gets the short /a/ form — it resolves the + // the terminal format gets the short /a/ form — it resolves the // slot/campaign/creative from the impression row instead of the query string. const clickUrl = format === TERMINAL_FORMAT_ID - ? `${env.siteUrl}/a/${impressionId}` + ? `${env.siteUrl}/a/${clickRef}` : `${env.siteUrl}/api/ads/click?i=${impressionId}&s=${slotId}&c=${campaign.id}&cr=${pick.id}`; return { diff --git a/lib/ads/shortcode.ts b/lib/ads/shortcode.ts new file mode 100644 index 00000000..c9f7a7e4 --- /dev/null +++ b/lib/ads/shortcode.ts @@ -0,0 +1,67 @@ +import crypto from "node:crypto"; + +// Short, URL-safe impression codes, so a paid terminal ad's click URL fits +// inside the ASCII box instead of dangling below it. +// +// The length is not arbitrary — it is what the narrowest supported box can +// afford: +// +// cols = 44 the minimum width /api/ads/motd accepts +// inner = cols - 4 = 40 usable columns between "| " and " |" +// "https://crawlproof.com/a/" 25 characters of fixed prefix +// ------------------------------------------------------------------ +// 40 - 25 = 15 columns left for the code +// +// 12 leaves three columns of headroom for a longer origin (a staging host, a +// trailing slash in siteUrl) while still being 71 bits of entropy: +// +// 62^12 ~= 3.2e21 ~= 2^71 +// +// For comparison the old form printed the raw impression UUID, 36 characters, +// which needed 61 columns and so never fit a 44-col box. +// +// Entropy matters because the code is the only thing standing between a +// stranger and a click charge on someone else's campaign: /a/ meters a +// click against the campaign named by the impression row. Guessing is the +// attack, so the space has to be far too large to sweep. It is deliberately +// well above the ~42 bits a 7-character code would have given. +export const SHORT_CODE_LENGTH = 12; + +// Base62. No look-alike stripping: these are copy-pasted or clicked, not read +// aloud, and dropping characters would cost entropy we are already budgeting +// tightly. +const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +export const SHORT_CODE_RE = new RegExp(`^[0-9A-Za-z]{${SHORT_CODE_LENGTH}}$`); + +/** True when `v` looks like an impression short code (not a UUID). */ +export function isShortCode(v: string | null | undefined): boolean { + return typeof v === "string" && SHORT_CODE_RE.test(v); +} + +// 256 is not a multiple of 62, so a plain `byte % 62` would make the first four +// symbols marginally more likely. Rejection sampling keeps the distribution +// flat, which is cheap here and means the 71-bit figure above is honest. +const LIMIT = 256 - (256 % ALPHABET.length); // 248 + +/** + * A cryptographically random base62 code. + * + * Uniqueness is enforced by a unique index on the column, not by this function + * — at 71 bits a collision is not a practical concern, but the index makes it + * an error rather than a silently mis-attributed click. + */ +export function generateShortCode(length = SHORT_CODE_LENGTH): string { + let out = ""; + while (out.length < length) { + // Over-fetch: on average ~3% of bytes are rejected, so one round is + // almost always enough. + const bytes = crypto.randomBytes(length - out.length + 8); + for (const b of bytes) { + if (b >= LIMIT) continue; + out += ALPHABET[b % ALPHABET.length]; + if (out.length === length) break; + } + } + return out; +} diff --git a/supabase/migrations/20260731130000_ad_impression_short_codes.sql b/supabase/migrations/20260731130000_ad_impression_short_codes.sql new file mode 100644 index 00000000..15a89f73 --- /dev/null +++ b/supabase/migrations/20260731130000_ad_impression_short_codes.sql @@ -0,0 +1,30 @@ +-- Short impression codes, so a paid terminal ad's click URL fits inside the +-- ASCII box. +-- +-- A terminal ad prints its click URL as literal text inside a box the caller +-- sized. The old form, https://crawlproof.com/a/, is 61 characters and +-- the narrowest supported box (44 cols) has 40 usable columns, so the URL was +-- always pushed outside the frame. A 12-character base62 code brings that to +-- 37 characters. See lib/ads/shortcode.ts for the width arithmetic. +-- +-- Both columns are additive and nullable, and the application tolerates their +-- absence, so this migration is safe to apply before or after the deploy that +-- starts using them. Existing rows keep resolving through their UUID, which +-- /a/[id] still accepts — click URLs already printed into people's MOTDs and +-- SSH banners must not break. + +alter table ad_impressions add column if not exists short_code text; + +-- The publisher's surface tag (?src=bbs, ?src=ssh-banner, …). It used to ride +-- the printed click URL as "&s=", which cost up to 35 more columns in a +-- box that had none to give. Recording it on the impression instead means the +-- printed URL is just /a/, and the click handler reads the tag back from +-- here. It also makes the tag queryable for per-surface reporting, which the +-- query-string form never was. +alter table ad_impressions add column if not exists src text; + +-- Partial: only real codes are constrained, so the pre-existing rows (all +-- NULL) cost nothing and the index stays small. +create unique index if not exists ad_impressions_short_code_key + on ad_impressions (short_code) + where short_code is not null; diff --git a/tests/contract/ads-short-code-serving.test.ts b/tests/contract/ads-short-code-serving.test.ts new file mode 100644 index 00000000..0b58423f --- /dev/null +++ b/tests/contract/ads-short-code-serving.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SHORT_CODE_RE } from "@/lib/ads/shortcode"; + +// serveAd now writes two new columns (short_code, src) that only exist after a +// migration this repo applies by hand. If the deploy wins that race, an insert +// naming them fails — and since the impression row is what a click resolves +// back to, a naive version would take *all* paid serving down until someone +// noticed. These pin the fallback: retry without the new columns, keep serving, +// and address the click by UUID instead. + +const state = vi.hoisted(() => ({ + inserts: [] as Record[], + // Set false to simulate a database that has not had the migration applied. + hasNewColumns: true, +})); + +const SLOT = { + id: "slot-1", + status: "active", + formats: ["terminal_ascii"], + owner_id: "pub-1", +}; + +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); + const namesNewColumns = "short_code" in payload || "src" in payload; + if (namesNewColumns && !state.hasNewColumns) { + // PostgREST on an unknown column: no row comes back. + return chain({ data: null, error: { message: "column does not exist" } }); + } + return chain({ + data: { id: "11111111-2222-3333-4444-555555555555", ...payload }, + error: null, + }); + }, + }; + } + return chain({ data: null, error: null }); + }, + }), +})); + +async function serve(src: string | null = "motd") { + const { serveAd } = await import("@/lib/ads/serve"); + return serveAd("slot-1", "terminal_ascii", { device: "terminal", src }); +} + +describe("serveAd short-code click URLs", () => { + beforeEach(() => { + vi.resetModules(); + state.inserts = []; + state.hasNewColumns = true; + }); + + it("addresses the click by short code once the columns exist", async () => { + const fill = await serve(); + expect(fill).not.toBeNull(); + const code = fill!.clickUrl.split("/a/")[1]; + expect(code).toMatch(SHORT_CODE_RE); + expect(state.inserts).toHaveLength(1); + expect(state.inserts[0].short_code).toMatch(SHORT_CODE_RE); + }); + + it("records the publisher surface tag on the impression, not in the URL", async () => { + const fill = await serve("bbs"); + expect(state.inserts[0].src).toBe("bbs"); + // The tag must not cost box width by riding along in the printed URL. + expect(fill!.clickUrl).not.toContain("bbs"); + expect(fill!.clickUrl).not.toContain("?"); + }); + + it("keeps serving when the migration has not been applied yet", async () => { + state.hasNewColumns = false; + const fill = await serve(); + expect(fill).not.toBeNull(); + // Two attempts: the first naming the new columns, the second without them. + expect(state.inserts).toHaveLength(2); + expect(state.inserts[0]).toHaveProperty("short_code"); + expect(state.inserts[1]).not.toHaveProperty("short_code"); + expect(state.inserts[1]).not.toHaveProperty("src"); + // The impression is still recorded, so the click still meters. + expect(state.inserts[1]).toMatchObject({ slot_id: "slot-1", campaign_id: "camp-1" }); + }); + + it("falls back to the UUID click URL when the code could not be stored", async () => { + state.hasNewColumns = false; + const fill = await serve(); + const ref = fill!.clickUrl.split("/a/")[1]; + expect(ref).not.toMatch(SHORT_CODE_RE); + expect(ref).toBe("11111111-2222-3333-4444-555555555555"); + }); + + it("issues a distinct code per paid fill", async () => { + // HOUSE_AD_ROTATION_RATE puts ~10% of otherwise-fillable requests on the + // unmetered house ad, which has no impression and so no /a/ code. Count + // only the paid fills. + const codes: string[] = []; + for (let i = 0; i < 60; i++) { + const fill = await serve(); + if (fill!.campaignId === "house") continue; + codes.push(fill!.clickUrl.split("/a/")[1]); + } + expect(codes.length).toBeGreaterThan(20); + for (const code of codes) expect(code).toMatch(SHORT_CODE_RE); + expect(new Set(codes).size).toBe(codes.length); + }); +}); diff --git a/tests/contract/ads-short-codes.test.ts b/tests/contract/ads-short-codes.test.ts new file mode 100644 index 00000000..779ae587 --- /dev/null +++ b/tests/contract/ads-short-codes.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + SHORT_CODE_LENGTH, + SHORT_CODE_RE, + generateShortCode, + isShortCode, +} from "@/lib/ads/shortcode"; +import { renderCreativeText } from "@/lib/ads/terminal"; +import type { AdCreative } from "@/lib/ads/formats"; + +// A paid terminal ad prints its click URL as literal text inside a box the +// caller sized. The UUID form was 61 characters (68 with a surface tag) against +// the 40 usable columns of a 44-col box, so it never fit. These pin the +// arithmetic that makes the short form fit, and the two compatibility rules +// that keep it from costing anyone money: +// +// - click URLs already printed into MOTDs and SSH banners still resolve +// - serving survives the deploy landing before the (hand-applied) migration + +const PROD = "https://crawlproof.com"; +const LEGACY_UUID = "2b1f0c94-8a1e-4c3d-9b77-1f0a2c3d4e5f"; + +function creative(over: Partial = {}): AdCreative { + return { + format: "terminal_ascii", + headline: "Ship faster with CrawlProof", + body: "AI-readable audits for your site, in one command.", + ctaText: "Try it free", + bgColor: "#0b0d10", + fgColor: "#e7e9ee", + accentColor: "#6ee7b7", + fontFamily: "system-ui", + logoUrl: null, + imageUrl: null, + ...over, + }; +} + +describe("generateShortCode", () => { + it("produces base62 codes of the declared length", () => { + for (let i = 0; i < 500; i++) { + const code = generateShortCode(); + expect(code).toHaveLength(SHORT_CODE_LENGTH); + expect(code).toMatch(SHORT_CODE_RE); + } + }); + + it("does not repeat across a large sample", () => { + const n = 20000; + const seen = new Set(Array.from({ length: n }, () => generateShortCode())); + expect(seen.size).toBe(n); + }); + + it("uses the whole alphabet roughly uniformly", () => { + // Guards the rejection sampling: a plain `byte % 62` would over-represent + // the first four symbols, quietly costing entropy the width budget is + // already tight on. + const freq = new Map(); + const n = 20000; + for (let i = 0; i < n; i++) { + for (const ch of generateShortCode()) freq.set(ch, (freq.get(ch) ?? 0) + 1); + } + expect(freq.size).toBe(62); + const expected = (n * SHORT_CODE_LENGTH) / 62; + for (const count of freq.values()) { + expect(Math.abs(count - expected) / expected).toBeLessThan(0.1); + } + }); + + it("tells a short code apart from a UUID", () => { + expect(isShortCode(generateShortCode())).toBe(true); + expect(isShortCode(LEGACY_UUID)).toBe(false); + expect(isShortCode("")).toBe(false); + expect(isShortCode(null)).toBe(false); + expect(isShortCode("../../etc/passwd")).toBe(false); + expect(isShortCode(`${generateShortCode()}x`)).toBe(false); + }); +}); + +describe("paid click URL width", () => { + it("fits inside the frame at every supported width", () => { + for (const cols of [44, 52, 60, 72, 120]) { + for (let i = 0; i < 50; i++) { + const url = `${PROD}/a/${generateShortCode()}`; + const text = renderCreativeText(creative(), url, { cols }); + for (const line of text.split("\n")) { + expect(line).toHaveLength(cols); + expect(line.startsWith("|") || line.startsWith("+")).toBe(true); + } + expect(text).toContain(url); + } + } + }); + + it("is short enough to leave headroom at the narrowest box", () => { + const url = `${PROD}/a/${generateShortCode()}`; + // cols 44 -> inner 40. Anything longer gets pushed outside the frame. + expect(url.length).toBeLessThanOrEqual(40); + }); + + it("the old UUID form did not fit, which is why this exists", () => { + expect(`${PROD}/a/${LEGACY_UUID}`.length).toBeGreaterThan(40); + }); +}); + +// --- click resolution ------------------------------------------------------ + +const db = vi.hoisted(() => ({ handler: vi.fn() })); + +vi.mock("@/lib/supabase/service", () => ({ + serviceClient: () => ({ + from: (table: string) => db.handler(table), + }), +})); + +const resolveClick = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/ads/serve", async (orig) => ({ + ...(await orig>()), + resolveClick, +})); + +vi.mock("@/lib/tracker/geo", () => ({ + clientIpFromHeaders: () => "203.0.113.9", + lookupGeo: async () => ({ countryCode: "US" }), +})); + +/** + * Minimal ad_impressions stub. + * + * `hasSrcColumn: false` reproduces the pre-migration database: any projection + * naming `src` comes back empty, exactly as PostgREST behaves on an unknown + * column. + */ +function impressionsTable(row: Record | null, hasSrcColumn = true) { + return (_table: string) => { + let wantsSrc = false; + let matched = true; + const chain: Record = { + select: (cols: string) => { + wantsSrc = cols.includes("src"); + return chain; + }, + eq: (column: string, value: unknown) => { + // Only answer for the column the row is actually addressed by. + if (row && row[column] !== value) matched = false; + return chain; + }, + maybeSingle: async () => { + if (!row || !matched) return { data: null, error: null }; + if (wantsSrc && !hasSrcColumn) { + return { data: null, error: { message: 'column "src" does not exist' } }; + } + const { src, ...rest } = row as { src?: unknown }; + return { data: wantsSrc ? row : rest, error: null }; + }, + }; + return chain; + }; +} + +const IMPRESSION = { + id: LEGACY_UUID, + short_code: "zCjQTqLAGEJJ", + slot_id: "slot-1", + campaign_id: "camp-1", + creative_id: "cre-1", + visitor_id: null, + src: "bbs", +}; + +async function click(url: string, id: string) { + const { GET } = await import("@/app/a/[id]/route"); + return GET(new Request(url) as never, { params: Promise.resolve({ id }) }); +} + +describe("/a/ click resolution", () => { + beforeEach(() => { + vi.resetModules(); + db.handler.mockReset(); + resolveClick.mockReset(); + resolveClick.mockResolvedValue("https://advertiser.example/landing"); + }); + + it("resolves a short code and meters the click", async () => { + db.handler.mockImplementation(impressionsTable(IMPRESSION)); + const res = await click(`${PROD}/a/zCjQTqLAGEJJ`, "zCjQTqLAGEJJ"); + expect(res.status).toBe(302); + expect(resolveClick).toHaveBeenCalledOnce(); + expect(resolveClick.mock.calls[0][0]).toMatchObject({ campaignId: "camp-1" }); + }); + + it("still resolves a legacy UUID, so already-printed banners keep working", async () => { + db.handler.mockImplementation(impressionsTable(IMPRESSION)); + const res = await click(`${PROD}/a/${LEGACY_UUID}`, LEGACY_UUID); + expect(res.status).toBe(302); + expect(resolveClick).toHaveBeenCalledOnce(); + }); + + it("rejects anything that is neither form without touching the database", async () => { + db.handler.mockImplementation(impressionsTable(IMPRESSION)); + for (const bad of ["../../etc/passwd", "'; drop table ad_impressions;--", "x"]) { + const res = await click(`${PROD}/a/${bad}`, bad); + expect(res.status).toBe(302); + // Bounced to the site root (env.siteUrl, which is localhost under test), + // never to a click-metering destination. + const loc = res.headers.get("location") ?? ""; + expect(new URL(loc).pathname).toBe("/"); + } + expect(resolveClick).not.toHaveBeenCalled(); + }); + + it("takes the surface tag from the impression row", async () => { + db.handler.mockImplementation(impressionsTable(IMPRESSION)); + const res = await click(`${PROD}/a/zCjQTqLAGEJJ`, "zCjQTqLAGEJJ"); + const loc = new URL(res.headers.get("location") ?? ""); + expect(loc.searchParams.get("utm_content")).toBe("bbs"); + }); + + it("falls back to the query string for URLs that still carry ?s=", async () => { + db.handler.mockImplementation(impressionsTable({ ...IMPRESSION, src: null })); + const res = await click(`${PROD}/a/${LEGACY_UUID}?s=ssh-banner`, LEGACY_UUID); + const loc = new URL(res.headers.get("location") ?? ""); + expect(loc.searchParams.get("utm_content")).toBe("ssh-banner"); + }); + + it("still resolves a legacy UUID when the src column does not exist yet", async () => { + // Deploy ahead of the hand-applied migration: the projection naming `src` + // fails, and the click must fall back rather than be dropped. + db.handler.mockImplementation(impressionsTable(IMPRESSION, false)); + const res = await click(`${PROD}/a/${LEGACY_UUID}`, LEGACY_UUID); + expect(res.status).toBe(302); + expect(resolveClick).toHaveBeenCalledOnce(); + }); +});