← back to Kickbacks Ai Upstream
test/portfolio.test.ts
201 lines
import { describe, it, expect, vi } from "vitest";
import { PortfolioClient, fetchPortfolioWithDemoFallback } from "../src/portfolio/client";
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response;
describe("PortfolioClient", () => {
it("maps S2's real flat shape into a PatchAd; caches with ttl", async () => {
const fetchMock = vi.fn(async () => ok({
ttl_seconds: 60,
ads: [{ ad_id: "a1", campaign_id: "c1", seat: "c1", weight: 1,
title_text: "Acme deploys faster than your CI now", icon_ref: "icon.a",
click_url: "https://acme.test/x" }],
}));
const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
const ad = await c.fetchAd("2.1.143");
// `seat` in the wire payload above is ignored by the parser and absent
// from the mapped ad (toEqual asserts the exact shape — no `seat` key).
expect(ad).toEqual({ adId: "a1", campaignId: "c1",
adText: "Acme deploys faster than your CI now", iconRef: "icon.a",
iconUrl: "", clickUrl: "https://acme.test/x", bannerEnabled: false,
sessionToken: "" });
});
it("banner_enabled:true in payload => bannerEnabled true", async () => {
const fetchMock = vi.fn(async () => ok({
ttl_seconds: 60,
ads: [{ ad_id: "a2", campaign_id: "c2", seat: "c2", weight: 1,
title_text: "Banner ad text", icon_ref: "icon.b",
click_url: "https://banner.test/x", banner_enabled: true }],
}));
const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
const ad = await c.fetchAd("2.1.143");
expect(ad?.bannerEnabled).toBe(true);
});
it("banner_enabled absent in payload => bannerEnabled false", async () => {
const fetchMock = vi.fn(async () => ok({
ttl_seconds: 60,
ads: [{ ad_id: "a3", campaign_id: "c3", seat: "c3", weight: 1,
title_text: "No banner ad text", icon_ref: "icon.c",
click_url: "https://nobanner.test/x" }],
}));
const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
const ad = await c.fetchAd("2.1.143");
expect(ad?.bannerEnabled).toBe(false);
});
it("empty ads -> null (valid: no patch)", async () => {
const c = new PortfolioClient("http://b", () => "tok",
(async () => ok({ ttl_seconds: 60, ads: [] })) as never);
expect(await c.fetchAd("2.1.143")).toBeNull();
});
it("on fetch error serves last good ad until ttl, then null", async () => {
let n = 0;
const fetchMock = vi.fn(async () => {
n++;
if (n === 1) return ok({ ttl_seconds: 0, ads: [{ ad_id: "a1", campaign_id: "c1",
seat: "c1", weight: 1, title_text: "x".repeat(35), icon_ref: "i",
click_url: "https://t/x" }] });
throw new Error("network down");
});
const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
expect((await c.fetchAd("2.1.143"))?.adId).toBe("a1"); // primes cache
// ttl_seconds:0 -> cache already expired, error -> null
expect(await c.fetchAd("2.1.143")).toBeNull();
});
describe("fetchDemoPortfolio (signed-out preview)", () => {
it("hits /v1/portfolio/demo with client_id and NO auth header, stamps demo:true", async () => {
const calls: { url: string; init: any }[] = [];
const fetchMock = vi.fn(async (url: string, init: any) => {
calls.push({ url, init });
return ok({ ttl_seconds: 60, ads: [{ ad_id: "a1", campaign_id: "c1",
seat: "c1", title_text: "x".repeat(35), icon_ref: "i",
click_url: "https://t/x", session_token: "demo-tok" }] });
});
// token() returns a value, but fetchDemoPortfolio must NOT send it as auth.
const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
const r = await c.fetchDemoPortfolio("2.1.143", "dev-123");
expect(calls[0].url).toBe(
"http://b/v1/portfolio/demo?claude_code_version=2.1.143&client_id=dev-123");
// No Authorization header on the public demo surface.
expect(calls[0].init.headers.authorization).toBeUndefined();
expect(r!.ads[0].demo).toBe(true);
expect(r!.ads[0].sessionToken).toBe("demo-tok");
expect(r!.ad?.demo).toBe(true);
});
it("real fetchPortfolio does NOT stamp demo", async () => {
const c = new PortfolioClient("http://b", () => "tok",
(async () => ok({ ttl_seconds: 60, ads: [{ ad_id: "a1", campaign_id: "c1",
seat: "c1", title_text: "x".repeat(35), icon_ref: "i",
click_url: "https://t/x" }] })) as never);
const r = await c.fetchPortfolio("2.1.143");
expect(r!.ads[0].demo).toBeUndefined();
});
});
});
describe("fetchPortfolioWithDemoFallback", () => {
const real = (token: string) => ok({
ttl_seconds: 60, ads: [{ ad_id: "real1", campaign_id: "c1", seat: "s",
title_text: "x".repeat(35), icon_ref: "i", click_url: "https://r.test",
session_token: token }],
});
const demo = ok({
ttl_seconds: 60, ads: [{ ad_id: "demo1", campaign_id: "cd", seat: "s",
title_text: "y".repeat(35), icon_ref: "i", click_url: "https://d.test",
session_token: "demo-tok" }],
});
const empty = ok({ ttl_seconds: 60, ads: [] });
// A non-2xx makes PortfolioClient.fetchPortfolio throw internally → null
// (the "hard failure / 401" signal, distinct from a valid-but-empty 200).
const fail = { ok: false, status: 401, json: async () => ({}) } as Response;
function authStub(over: Partial<{ token: string | null; refresh: () => Promise<boolean> }> = {}) {
return {
accessToken: () => (("token" in over) ? over.token! : "tok"),
clientId: () => "cid",
refresh: over.refresh ?? (async () => false),
};
}
it("signed in with a valid token → real portfolio (no refresh, no demo)", async () => {
const refresh = vi.fn(async () => true);
const fetchMock = vi.fn(async (_url: string) => real("real-tok"));
const pc = new PortfolioClient("http://b", () => "tok", fetchMock as never);
const r = await fetchPortfolioWithDemoFallback(pc, authStub({ token: "tok", refresh }), "v");
expect(r!.ad?.adId).toBe("real1");
expect(refresh).not.toHaveBeenCalled(); // valid token ⇒ no refresh
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain("/v1/portfolio?"); // not demo
});
it("signed out (no token) → demo straight away, never refreshes", async () => {
const refresh = vi.fn(async () => false);
const fetchMock = vi.fn(async (_url: string) => demo);
const pc = new PortfolioClient("http://b", () => null, fetchMock as never);
const r = await fetchPortfolioWithDemoFallback(pc, authStub({ token: null, refresh }), "v");
expect(r!.ad?.adId).toBe("demo1");
expect(refresh).not.toHaveBeenCalled();
expect(fetchMock.mock.calls[0][0]).toContain("/v1/portfolio/demo");
});
it("signed in, EMPTY inventory (valid 200) → keep it, NO refresh, NO demo", async () => {
// The critical guard: empty inventory must not be mistaken for a dead token.
// Refreshing here could clear a valid session on a transient empty poll.
const refresh = vi.fn(async () => true);
const fetchMock = vi.fn(async (_url: string) => empty);
const pc = new PortfolioClient("http://b", () => "tok", fetchMock as never);
const r = await fetchPortfolioWithDemoFallback(pc, authStub({ token: "tok", refresh }), "v");
expect(r!.ad).toBeNull(); // empty portfolio preserved
expect(refresh).not.toHaveBeenCalled(); // empty ≠ dead token
expect(fetchMock).toHaveBeenCalledTimes(1); // no demo fetch
});
it("dead token (real fetch hard-fails/401 → null, refresh fails) → demo fallback", async () => {
let tok: string | null = "stale";
const refresh = vi.fn(async () => { tok = null; return false; });
const fetchMock = vi.fn(async (url: string) =>
url.includes("/demo") ? demo : fail); // real fetch → null
const pc = new PortfolioClient("http://b", () => tok, fetchMock as never);
const auth = { accessToken: () => tok, clientId: () => "cid", refresh };
const r = await fetchPortfolioWithDemoFallback(pc, auth, "v");
expect(refresh).toHaveBeenCalledTimes(1);
expect(r!.ad?.adId).toBe("demo1"); // demo fallback served
});
it("TRANSIENT refresh failure (token kept) → null, NO demo demotion (audit #10)", async () => {
// Offline at activation: the real fetch throws, the forced refresh fails
// TRANSIENTLY (network, not 401) — post-#10, refresh() keeps the access
// token. The fallback must NOT demote the session to demo (demo metrics
// credit no user); it serves nothing this tick and the next periodic
// refresh retries. Pre-fix: any failed refresh routed straight to demo.
const refresh = vi.fn(async () => false); // token survives (transient)
const fetchMock = vi.fn(async (url: string) => {
if (url.includes("/demo")) return demo; // trap: wrong demotion
throw new Error("ENOTFOUND"); // real fetch: offline
});
const pc = new PortfolioClient("http://b", () => "tok", fetchMock as never);
const r = await fetchPortfolioWithDemoFallback(pc, authStub({ token: "tok", refresh }), "v");
expect(refresh).toHaveBeenCalledTimes(1);
expect(r).toBeNull(); // nothing this tick — not demo
expect(fetchMock.mock.calls.some(([u]) => String(u).includes("/demo")))
.toBe(false); // demo endpoint never hit
});
it("dead-then-revived token (real hard-fails, refresh re-mints) → real portfolio", async () => {
let tok: string | null = "stale";
const refresh = vi.fn(async () => { tok = "fresh"; return true; });
const fetchMock = vi.fn(async (url: string) =>
url.includes("/demo") ? demo : (tok === "fresh" ? real("fresh-tok") : fail));
const pc = new PortfolioClient("http://b", () => tok, fetchMock as never);
const auth = { accessToken: () => tok, clientId: () => "cid", refresh };
const r = await fetchPortfolioWithDemoFallback(pc, auth, "v");
expect(refresh).toHaveBeenCalledTimes(1);
expect(r!.ad?.adId).toBe("real1"); // re-minted ⇒ real ads
});
});