From 37d6772110660b2909dd621397352264da9ab6e4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 12:49:32 +0000 Subject: [PATCH] feat(ads): short impression codes so paid terminal ads fit the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #175, which fixed the house ad's click URL but left the paid one. A paid terminal ad printed: https://crawlproof.com/a/ 61 chars https://crawlproof.com/a/?s=motd 68 chars against the 40 usable columns of a 44-col box, so the URL was always pushed outside the frame. Two changes bring it to 37: 1. A 12-character base62 short code addresses the impression instead of its UUID. The length is derived, not picked: 40 usable columns minus the 25-character "https://crawlproof.com/a/" prefix leaves 15, and 12 keeps three columns of headroom for a longer origin while still being 71 bits. Generated with rejection sampling — a plain byte % 62 would have skewed the first four symbols and quietly cost entropy the width budget is already tight on. 2. The publisher's surface tag moves out of the URL and onto the impression row. As "&s=" it cost up to 35 more columns of a box that has 40; /a/ now reads it back off the row to build utm_content. It is also queryable there, which the query string never was. Entropy matters because the code is all that stands between a stranger and a click charge on someone else's campaign, so this is deliberately well above the ~42 bits a 7-character code would have given. Compatibility, both directions: - /a/ still accepts UUIDs. Click URLs printed before this change are sitting in people's MOTDs, SSH banners and BBS screens and are never reissued; they must keep resolving. The ?s= query form is still read as a fallback for the same reason. - Migrations here are applied by hand, so the deploy can land before the schema. Both new columns are additive and nullable, the impression insert retries without them if they are missing, and the click URL falls back to the UUID form. A wide URL is cosmetic; a dropped impression is a lost sale. Either apply order is safe. Co-Authored-By: Claude Opus 5 (1M context) --- app/a/[id]/route.ts | 61 ++++- app/api/ads/motd/route.ts | 16 +- lib/ads/serve.ts | 57 +++-- lib/ads/shortcode.ts | 67 +++++ ...260731130000_ad_impression_short_codes.sql | 30 +++ tests/contract/ads-short-code-serving.test.ts | 160 ++++++++++++ tests/contract/ads-short-codes.test.ts | 234 ++++++++++++++++++ 7 files changed, 599 insertions(+), 26 deletions(-) create mode 100644 lib/ads/shortcode.ts create mode 100644 supabase/migrations/20260731130000_ad_impression_short_codes.sql create mode 100644 tests/contract/ads-short-code-serving.test.ts create mode 100644 tests/contract/ads-short-codes.test.ts 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(); + }); +});