← back to Kickbacks Ai Upstream
test/adRotation-token-refresh.test.ts
234 lines
import { describe, it, expect, vi, afterEach } from "vitest";
import { setupAdRotation, type AdRotationDeps } from "../src/activation/adRotation";
import type { PatchAd, PortfolioResponse } from "../src/portfolio/client";
// Regression: the server mints a FRESH session token on every /v1/portfolio(/demo)
// fetch (300s TTL). adRotation used to discard the refreshed response whenever the
// ad SET (adId signature) was unchanged — so the in-use `activeAd.sessionToken`
// aged out and every billable view event started returning 403 after ~5 min on
// stable inventory. The fix adopts the fresh token without re-patching the overlay.
function ad(adId: string, sessionToken: string): PatchAd {
return {
adId, campaignId: "c-" + adId, adText: "Ad " + adId,
iconRef: "i", iconUrl: "", clickUrl: "https://x.test",
bannerEnabled: false, sessionToken,
};
}
function resp(ads: PatchAd[]): PortfolioResponse {
return {
ad: ads[0] ?? null, ads, queueId: "q", ttlMs: 60_000,
rotationIntervalMs: 120_000, viewThresholdMs: 3_000, balances: null,
};
}
function makeDeps(initial: PortfolioResponse, fetchImpl: () => Promise<PortfolioResponse>) {
const timers: NodeJS.Timeout[] = [];
const activeAdRef = { current: initial.ads[0] };
const adRef = { current: initial.ads[0] as PatchAd | null };
const applyPatch = vi.fn(() => ({ ok: true }));
const deps = {
adapter: { applyPatch, isPatched: () => true,
preflight: () => ({ compatible: true }), restore: () => {} },
portfolio: { fetchPortfolio: fetchImpl, fetchDemoPortfolio: fetchImpl },
auth: { accessToken: () => "tok", clientId: () => "cid" },
debugCtl: { setPortfolioAd: vi.fn() },
session: { set: vi.fn() },
ccVersion: "2.1.167",
port: 12345,
patchParams: { adText: "", iconRef: "", iconUrl: "", clickUrl: "" },
activeAdRef,
corrRef: { current: "corr" },
adRef,
impDedupe: { reset: vi.fn() },
reapplyCodex: null,
timers,
} as unknown as AdRotationDeps;
return { deps, timers, activeAdRef, adRef, applyPatch };
}
describe("adRotation session-token refresh", () => {
const cleanups: Array<() => void> = [];
afterEach(() => { cleanups.forEach((c) => c()); cleanups.length = 0; });
it("adopts the fresh session token when the ad set is unchanged (no re-patch)", async () => {
const initial = resp([ad("a1", "tok-OLD")]);
const fetchImpl = vi.fn(async () => resp([ad("a1", "tok-NEW")])); // same adId, new token
const { deps, timers, activeAdRef, adRef, applyPatch } = makeDeps(initial, fetchImpl);
cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
const handle = setupAdRotation(deps, initial);
applyPatch.mockClear(); // ignore any setup-time apply
await handle.refreshNow(false); // unchanged ad set → must still refresh token
expect(fetchImpl).toHaveBeenCalled();
expect(activeAdRef.current.sessionToken).toBe("tok-NEW");
expect(adRef.current?.sessionToken).toBe("tok-NEW");
// Unchanged text/clickUrl ⇒ the overlay must NOT be re-patched on a pure
// token refresh (no visible churn, no loopback re-mint).
expect(applyPatch).not.toHaveBeenCalled();
});
it("still swaps fully (re-patch) when the ad actually changes", async () => {
const initial = resp([ad("a1", "tok-OLD")]);
const fetchImpl = vi.fn(async () => resp([ad("a2", "tok-A2")])); // different adId
const { deps, timers, activeAdRef, applyPatch } = makeDeps(initial, fetchImpl);
cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
const handle = setupAdRotation(deps, initial);
applyPatch.mockClear();
await handle.refreshNow(false);
expect(activeAdRef.current.adId).toBe("a2");
expect(activeAdRef.current.sessionToken).toBe("tok-A2");
expect(applyPatch).toHaveBeenCalled(); // real ad change ⇒ overlay re-patched
});
// BL-187: the demo stamp must travel WITH the adopted token. A mid-session
// demotion can return the SAME adId from the demo portfolio; adopting only
// the token left the object demo:false, so the status bar (whose signed-out
// gate is `!ad.demo` — it has no signedIn() probe) kept showing and billing
// ads while signed out.
it("adopts the demo stamp when a demotion swaps to demo ads with the same id", async () => {
const initial = resp([ad("a1", "tok-REAL")]); // real: no demo flag
const fetchImpl = vi.fn(async () =>
resp([{ ...ad("a1", "demo-tok"), demo: true }])); // demoted, same adId
const { deps, timers, activeAdRef, adRef, applyPatch } = makeDeps(initial, fetchImpl);
cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
const handle = setupAdRotation(deps, initial);
applyPatch.mockClear();
await handle.refreshNow(false);
expect(activeAdRef.current.sessionToken).toBe("demo-tok");
expect(activeAdRef.current.demo).toBe(true); // statusbar gate re-engages
expect(adRef.current?.demo).toBe(true);
expect(applyPatch).not.toHaveBeenCalled(); // still a churn-free adopt
});
it("clears the demo stamp when a re-auth swaps back to real ads", async () => {
const initial = resp([{ ...ad("a1", "demo-tok"), demo: true }]);
const fetchImpl = vi.fn(async () => resp([ad("a1", "tok-REAL")])); // real again
const { deps, timers, activeAdRef, adRef } = makeDeps(initial, fetchImpl);
cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
const handle = setupAdRotation(deps, initial);
await handle.refreshNow(false);
expect(activeAdRef.current.sessionToken).toBe("tok-REAL");
expect(activeAdRef.current.demo).toBeFalsy(); // real ads aren't suppressed
expect(adRef.current?.demo).toBeFalsy();
});
});
// Dead-token recovery (the "frozen ads" 401 loop, 2026-06-11): a client whose
// cached access token the server rejects used to 401 on /v1/portfolio every
// 60s FOREVER — refreshPortfolio swallowed the error, no fresh inventory ever
// arrived, and every surface kept the last-baked creative. The 60s refresh
// now routes through fetchPortfolioWithDemoFallback (same ladder as
// activation): one auth refresh, demote to demo ONLY on authoritative
// rejection, hold (no demotion) on transient failure.
describe("adRotation dead-token recovery (frozen-ads 401 loop)", () => {
const cleanups: Array<() => void> = [];
afterEach(() => { cleanups.forEach((c) => c()); cleanups.length = 0; });
function makeAuthDeps(initial: PortfolioResponse, opts: {
fetchPortfolio: () => Promise<PortfolioResponse | null>;
fetchDemoPortfolio: () => Promise<PortfolioResponse | null>;
refresh: () => Promise<boolean>;
token: () => string | null;
}) {
const timers: NodeJS.Timeout[] = [];
const activeAdRef = { current: initial.ads[0] };
const adRef = { current: initial.ads[0] as PatchAd | null };
const applyPatch = vi.fn(() => ({ ok: true }));
const deps = {
adapter: { applyPatch, isPatched: () => true,
preflight: () => ({ compatible: true }), restore: () => {} },
portfolio: { fetchPortfolio: opts.fetchPortfolio,
fetchDemoPortfolio: opts.fetchDemoPortfolio },
auth: { accessToken: opts.token, clientId: () => "cid",
refresh: opts.refresh },
debugCtl: { setPortfolioAd: vi.fn() },
session: { set: vi.fn() },
ccVersion: "2.1.167",
port: 12345,
patchParams: { adText: "", iconRef: "", iconUrl: "", clickUrl: "" },
activeAdRef,
corrRef: { current: "corr" },
adRef,
impDedupe: { reset: vi.fn() },
reapplyCodex: null,
timers,
} as unknown as AdRotationDeps;
return { deps, timers, activeAdRef, adRef, applyPatch };
}
it("authoritative rejection: 401 portfolio + failed refresh that CLEARS "
+ "the token demotes to DEMO ads instead of spinning frozen", async () => {
const initial = resp([ad("a1", "tok-DEAD")]);
let token: string | null = "tok-DEAD";
const fetchPortfolio = vi.fn(async () => null); // 401, no cache
const fetchDemoPortfolio = vi.fn(async () =>
resp([{ ...ad("a1", "demo-tok"), demo: true }]));
const refresh = vi.fn(async () => { token = null; return false; });
const { deps, timers, activeAdRef, adRef } = makeAuthDeps(initial,
{ fetchPortfolio, fetchDemoPortfolio, refresh, token: () => token });
cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
const handle = setupAdRotation(deps, initial);
await handle.refreshNow(false);
expect(refresh).toHaveBeenCalledTimes(1);
expect(fetchDemoPortfolio).toHaveBeenCalled();
// Same adId → churn-free adopt of the demo token + stamp (BL-187 path).
expect(activeAdRef.current.sessionToken).toBe("demo-tok");
expect(activeAdRef.current.demo).toBe(true);
expect(adRef.current?.demo).toBe(true);
});
it("transient failure: refresh fails but the token survives → HOLD the "
+ "current ad (no demo demotion, no churn)", async () => {
const initial = resp([ad("a1", "tok-MAYBE-FINE")]);
const fetchPortfolio = vi.fn(async () => null); // offline this tick
const fetchDemoPortfolio = vi.fn(async () =>
resp([{ ...ad("a1", "demo-tok"), demo: true }]));
const refresh = vi.fn(async () => false); // transport failure
const { deps, timers, activeAdRef, applyPatch } = makeAuthDeps(initial,
{ fetchPortfolio, fetchDemoPortfolio, refresh,
token: () => "tok-MAYBE-FINE" }); // token KEPT
cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
const handle = setupAdRotation(deps, initial);
applyPatch.mockClear();
await handle.refreshNow(false);
expect(fetchDemoPortfolio).not.toHaveBeenCalled(); // no demotion
expect(activeAdRef.current.sessionToken).toBe("tok-MAYBE-FINE");
expect(activeAdRef.current.demo).toBeFalsy();
expect(applyPatch).not.toHaveBeenCalled();
});
it("revived token: 401 portfolio + successful refresh re-fetches REAL ads "
+ "(no demo detour)", async () => {
const initial = resp([ad("a1", "tok-OLD")]);
let refreshed = false;
const fetchPortfolio = vi.fn(async () =>
refreshed ? resp([ad("a1", "tok-REVIVED")]) : null);
const fetchDemoPortfolio = vi.fn(async () => null);
const refresh = vi.fn(async () => { refreshed = true; return true; });
const { deps, timers, activeAdRef } = makeAuthDeps(initial,
{ fetchPortfolio, fetchDemoPortfolio, refresh, token: () => "tok-OLD" });
cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
const handle = setupAdRotation(deps, initial);
await handle.refreshNow(false);
expect(refresh).toHaveBeenCalledTimes(1);
expect(fetchDemoPortfolio).not.toHaveBeenCalled();
expect(activeAdRef.current.sessionToken).toBe("tok-REVIVED");
expect(activeAdRef.current.demo).toBeFalsy();
});
});