← back to Kickbacks Ai Upstream

test/rotation.test.ts

410 lines

import { describe, it, expect, afterEach, vi } from "vitest";
import { Loopback, type LoopbackAdPayload } from "../src/loopback";
import { PortfolioClient, type PatchAd,
  type PortfolioResponse } from "../src/portfolio/client";
import { setupAdRotation, clearAdRotationOnSignOut,
  type AdRotationDeps } from "../src/activation/adRotation";

const AD_LINEAR: PatchAd = {
  adId: "ad-linear", campaignId: "c-linear",
  adText: "Linear -- plan, build, ship", iconRef: "icon.linear",
  iconUrl: "https://icons.test/linear.png", clickUrl: "https://linear.app",
  bannerEnabled: false, sessionToken: "tok-linear",
};

const AD_RAILWAY: PatchAd = {
  adId: "ad-railway", campaignId: "c-railway",
  adText: "Railway -- deploy in seconds", iconRef: "icon.railway",
  iconUrl: "https://icons.test/railway.png", clickUrl: "https://railway.app",
  bannerEnabled: false, sessionToken: "tok-railway",
};

const AD_WARP: PatchAd = {
  adId: "ad-warp", campaignId: "c-warp",
  adText: "Warp -- the terminal reimagined", iconRef: "icon.warp",
  iconUrl: "https://icons.test/warp.png", clickUrl: "https://warp.dev",
  bannerEnabled: false, sessionToken: "tok-warp",
};

let lb: Loopback | null = null;
afterEach(async () => { if (lb) { await lb.stop(); lb = null; } });

describe("rotation → loopback /ad contract", () => {
  it("getCurrentAd reflects the most recent ad written to it", async () => {
    let currentAd: LoopbackAdPayload | null = {
      adId: AD_LINEAR.adId, adText: AD_LINEAR.adText,
      clickUrl: AD_LINEAR.clickUrl, iconUrl: AD_LINEAR.iconUrl,
      campaignId: AD_LINEAR.campaignId,
    };
    lb = new Loopback({
      onEvent: () => {}, onClick: () => {},
      getActivity: () => ({}),
      getCurrentAd: () => currentAd,
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;

    // Initial state: Linear
    let r = await (await fetch(`${base}/ad`)).json();
    expect(r.adId).toBe("ad-linear");
    expect(r.adText).toBe("Linear -- plan, build, ship");

    // Simulate rotation: update the closure variable (mirrors applyAd writing activeAd)
    currentAd = {
      adId: AD_RAILWAY.adId, adText: AD_RAILWAY.adText,
      clickUrl: AD_RAILWAY.clickUrl, iconUrl: AD_RAILWAY.iconUrl,
      campaignId: AD_RAILWAY.campaignId,
    };

    // Loopback must now serve the rotated ad
    r = await (await fetch(`${base}/ad`)).json();
    expect(r.adId).toBe("ad-railway");
    expect(r.adText).toBe("Railway -- deploy in seconds");
    expect(r.clickUrl).toBe("https://railway.app");
  });

  it("getCurrentAd serves empty object when ad is null (no-ad state)", async () => {
    lb = new Loopback({
      onEvent: () => {}, onClick: () => {},
      getActivity: () => ({}),
      getCurrentAd: () => null,
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
    const r = await (await fetch(`${base}/ad`)).json();
    expect(r).toEqual({});
  });

  it("multiple rapid rotations always serve the latest ad", async () => {
    const ads = [AD_LINEAR, AD_RAILWAY, AD_WARP];
    let idx = 0;
    lb = new Loopback({
      onEvent: () => {}, onClick: () => {},
      getActivity: () => ({}),
      getCurrentAd: () => {
        const a = ads[idx];
        return { adId: a.adId, adText: a.adText,
          clickUrl: a.clickUrl, iconUrl: a.iconUrl,
          campaignId: a.campaignId };
      },
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;

    for (let i = 0; i < ads.length; i++) {
      idx = i;
      const r = await (await fetch(`${base}/ad`)).json();
      expect(r.adId).toBe(ads[i].adId);
      expect(r.adText).toBe(ads[i].adText);
    }
  });
});

describe("PortfolioClient rotation fields", () => {
  it("parses rotation_interval_seconds into rotationIntervalMs", async () => {
    const fetchMock = vi.fn(async () => ({
      ok: true, status: 200,
      json: async () => ({
        ttl_seconds: 60,
        rotation_interval_seconds: 45,
        queue_id: "q123",
        ads: [
          { ad_id: "a1", campaign_id: "c1", title_text: "Ad one text here ok",
            icon_ref: "i", click_url: "https://one.test" },
          { ad_id: "a2", campaign_id: "c2", title_text: "Ad two text here ok",
            icon_ref: "i", click_url: "https://two.test" },
        ],
      }),
    }) as unknown as Response);
    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
    const r = await c.fetchPortfolio("2.1.143");
    expect(r).not.toBeNull();
    expect(r!.rotationIntervalMs).toBe(45_000);
    expect(r!.ads).toHaveLength(2);
    expect(r!.queueId).toBe("q123");
  });

  it("defaults rotationIntervalMs to 120s when server omits the field", async () => {
    const fetchMock = vi.fn(async () => ({
      ok: true, status: 200,
      json: async () => ({
        ttl_seconds: 60,
        ads: [{ ad_id: "a1", campaign_id: "c1", title_text: "x".repeat(30),
                icon_ref: "i", click_url: "https://x.test" }],
      }),
    }) as unknown as Response);
    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
    const r = await c.fetchPortfolio("2.1.143");
    expect(r!.rotationIntervalMs).toBe(120_000);
  });

  it("returns multiple ads in queue order for client-side rotation", async () => {
    const fetchMock = vi.fn(async () => ({
      ok: true, status: 200,
      json: async () => ({
        ttl_seconds: 60,
        rotation_interval_seconds: 60,
        ads: [
          { ad_id: "first", campaign_id: "c1", title_text: "First ad visible here",
            icon_ref: "i", click_url: "https://first.test" },
          { ad_id: "second", campaign_id: "c2", title_text: "Second ad visible here",
            icon_ref: "i", click_url: "https://second.test" },
          { ad_id: "third", campaign_id: "c3", title_text: "Third ad visible here",
            icon_ref: "i", click_url: "https://third.test" },
        ],
      }),
    }) as unknown as Response);
    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
    const r = await c.fetchPortfolio("2.1.143");
    expect(r!.ads.map((a) => a.adId)).toEqual(["first", "second", "third"]);
    expect(r!.ad?.adId).toBe("first");
  });

  it("parses balances alongside rotation fields (W4 full shape)", async () => {
    const fetchMock = vi.fn(async () => ({
      ok: true, status: 200,
      json: async () => ({
        ttl_seconds: 60,
        rotation_interval_seconds: 60,
        view_threshold_seconds: 15,
        queue_id: "qABC",
        balances: { lifetime_usd: "12.34", today_usd: "1.50",
                    last_updated_ms: 1700000000000 },
        ads: [{ ad_id: "a1", campaign_id: "c1", title_text: "x".repeat(30),
                icon_ref: "i", click_url: "https://x.test",
                session_token: "sess1" }],
      }),
    }) as unknown as Response);
    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
    const r = await c.fetchPortfolio("2.1.143");
    expect(r!.balances).toEqual({
      lifetimeUsd: "12.34", todayUsd: "1.50", lastUpdatedMs: 1700000000000,
    });
    expect(r!.viewThresholdMs).toBe(15_000);
    expect(r!.ads[0].sessionToken).toBe("sess1");
  });
});

describe("rotation index cycling", () => {
  it("rotateNext cycles through ads in order and wraps around", () => {
    const ads = [AD_LINEAR, AD_RAILWAY, AD_WARP];
    let rotationIdx = 0;
    const applied: string[] = [];

    function rotateNext() {
      if (ads.length < 2) return;
      rotationIdx = (rotationIdx + 1) % ads.length;
      applied.push(ads[rotationIdx].adId);
    }

    rotateNext(); // -> idx 1 (Railway)
    rotateNext(); // -> idx 2 (Warp)
    rotateNext(); // -> idx 0 (Linear, wrap)
    rotateNext(); // -> idx 1 (Railway)

    expect(applied).toEqual([
      "ad-railway", "ad-warp", "ad-linear", "ad-railway",
    ]);
  });

  it("rotateNext is a no-op with a single ad", () => {
    const ads = [AD_LINEAR];
    let rotationIdx = 0;
    let called = false;

    function rotateNext() {
      if (ads.length < 2) return;
      rotationIdx = (rotationIdx + 1) % ads.length;
      called = true;
    }

    rotateNext();
    expect(called).toBe(false);
    expect(rotationIdx).toBe(0);
  });

  it("refreshPortfolio resets rotation index to 0", () => {
    let rotationIdx = 2;
    const newAds = [AD_WARP, AD_LINEAR];

    // Simulates the refresh logic
    rotationIdx = 0;
    const firstAd = newAds[rotationIdx];

    expect(rotationIdx).toBe(0);
    expect(firstAd.adId).toBe("ad-warp");
  });
});

// ---------------------------------------------------------------------------
// Real setupAdRotation wiring: sign-out clear (audit #20) and the refresh
// epoch that discards stale in-flight portfolio responses (audit #32).
// ---------------------------------------------------------------------------
function mkResp(ads: PatchAd[]): PortfolioResponse {
  return { ad: ads[0] ?? null, ads, queueId: "q", ttlMs: 60_000,
    rotationIntervalMs: 120_000, viewThresholdMs: 3_000, balances: null };
}

function mkRotationDeps(opts: {
  token?: () => string | null;
  fetchPortfolio?: () => Promise<PortfolioResponse | null>;
  fetchDemoPortfolio?: () => Promise<PortfolioResponse | null>;
  initialAd?: PatchAd;
} = {}) {
  const timers: NodeJS.Timeout[] = [];
  const adRef = { current: (opts.initialAd ?? null) as PatchAd | null };
  const activeAdRef = { current: (opts.initialAd ?? null) as PatchAd | null };
  const applyPatch = vi.fn(() => ({ ok: true }));
  const sessionSet = vi.fn();
  const deps = {
    adapter: { applyPatch, isPatched: () => true,
               preflight: () => ({ compatible: true }), restore: () => {} },
    portfolio: { fetchPortfolio: opts.fetchPortfolio ?? (async () => null),
                 fetchDemoPortfolio: opts.fetchDemoPortfolio ?? (async () => null) },
    auth: { accessToken: opts.token ?? (() => "tok"), clientId: () => "cid" },
    debugCtl: { setPortfolioAd: vi.fn() },
    session: { set: sessionSet },
    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, adRef, activeAdRef, applyPatch, sessionSet };
}

describe("sign-out clear (audit #20)", () => {
  it("clear() empties the queue, nulls the shared ad refs, and disarms rotation", () => {
    const { deps, timers, adRef, activeAdRef, sessionSet } =
      mkRotationDeps({ initialAd: AD_LINEAR });
    try {
      const handle = setupAdRotation(deps, mkResp([AD_LINEAR, AD_RAILWAY]));
      expect(handle.rotationTimer).not.toBeNull(); // 2 ads ⇒ rotation armed

      handle.clear();

      expect(handle.adQueue).toEqual([]);
      expect(handle.rotationTimer).toBeNull();
      expect(adRef.current).toBeNull();
      expect(activeAdRef.current).toBeNull();
      expect(sessionSet).toHaveBeenCalledWith({ hasAd: false });
    } finally { timers.forEach((t) => clearInterval(t)); }
  });

  it("rotation never re-patches CC after clear() (the sign-out re-patch bug)", () => {
    vi.useFakeTimers();
    const { deps, timers, applyPatch } = mkRotationDeps({
      initialAd: AD_LINEAR, token: () => null });
    try {
      const handle = setupAdRotation(deps, mkResp([AD_LINEAR, AD_RAILWAY]));
      handle.clear();
      applyPatch.mockClear();
      // Pre-fix the still-armed 120s rotation timer applied the leftover REAL
      // ad — re-patching CC right after doSignOut had restored it.
      vi.advanceTimersByTime(10 * 120_000);
      expect(applyPatch).not.toHaveBeenCalled();
    } finally {
      timers.forEach((t) => clearInterval(t));
      vi.useRealTimers();
    }
  });

  it("clearAdRotationOnSignOut() reaches the live rotation (module hook)", () => {
    const { deps, timers, adRef } = mkRotationDeps({ initialAd: AD_LINEAR });
    try {
      const handle = setupAdRotation(deps, mkResp([AD_LINEAR]));
      clearAdRotationOnSignOut();
      expect(handle.adQueue).toEqual([]);
      expect(adRef.current).toBeNull();
    } finally { timers.forEach((t) => clearInterval(t)); }
  });

  it("clear() discards an in-flight refresh — a late response can't resurrect ads", async () => {
    let resolveReal!: (r: PortfolioResponse | null) => void;
    const pending = new Promise<PortfolioResponse | null>((res) => { resolveReal = res; });
    const { deps, timers, adRef } = mkRotationDeps({
      initialAd: AD_LINEAR, token: () => "tok", fetchPortfolio: () => pending });
    try {
      const handle = setupAdRotation(deps, mkResp([AD_LINEAR]));
      const inflight = handle.refreshNow(false); // fetch in flight…
      handle.clear();                            // …sign-out lands mid-flight
      resolveReal(mkResp([AD_RAILWAY]));         // stale response arrives late
      await inflight;
      expect(adRef.current).toBeNull();
      expect(handle.adQueue).toEqual([]);
    } finally { timers.forEach((t) => clearInterval(t)); }
  });

  it("the next (demo) portfolio apply re-populates the queue and re-arms rotation", async () => {
    let token: string | null = "tok";
    const { deps, timers, adRef } = mkRotationDeps({
      initialAd: AD_LINEAR, token: () => token,
      fetchDemoPortfolio: async () => mkResp([AD_RAILWAY, AD_WARP]) });
    try {
      const handle = setupAdRotation(deps, mkResp([AD_LINEAR]));
      handle.clear();
      token = null;                   // signed out now
      await handle.refreshNow(false); // the 60s refresh-timer path
      expect(adRef.current?.adId).toBe("ad-railway");
      expect(handle.adQueue).toHaveLength(2);
      expect(handle.rotationTimer).not.toBeNull();
    } finally { timers.forEach((t) => clearInterval(t)); }
  });
});

describe("stale in-flight refresh epoch (audit #32)", () => {
  it("a DEMO fetch resolving after the forced sign-in swap is discarded", async () => {
    let token: string | null = null;
    let resolveDemo!: (r: PortfolioResponse | null) => void;
    const demoPending = new Promise<PortfolioResponse | null>(
      (res) => { resolveDemo = res; });
    const { deps, timers, adRef, activeAdRef } = mkRotationDeps({
      initialAd: AD_LINEAR, token: () => token,
      fetchDemoPortfolio: () => demoPending,
      fetchPortfolio: async () => mkResp([AD_WARP]) });
    try {
      const handle = setupAdRotation(deps, mkResp([AD_LINEAR]));
      const inflight = handle.refreshNow(false); // signed-out demo fetch (slow)
      token = "tok";                             // sign-in completes mid-flight
      await handle.refreshNow(true);             // forced real-ad swap
      expect(adRef.current?.adId).toBe("ad-warp");

      resolveDemo(mkResp([AD_RAILWAY]));         // stale demo response lands
      await inflight;

      // Pre-fix the late demo response force-applied AD_RAILWAY (demo ads +
      // demo tokens) over the just-applied real ad on a signed-in client.
      expect(adRef.current?.adId).toBe("ad-warp");
      expect(activeAdRef.current?.adId).toBe("ad-warp");
      expect(handle.lastAdSetSig).toBe("ad-warp");
    } finally { timers.forEach((t) => clearInterval(t)); }
  });
});

describe("ad change detection (adChanged guard)", () => {
  it("detects change when adId differs", () => {
    let currentAdId = "ad-linear";
    const next = AD_RAILWAY;
    const adChanged = next.adId !== currentAdId;
    expect(adChanged).toBe(true);
  });

  it("skips update when same ad re-applied", () => {
    let currentAdId = "ad-linear";
    const next = AD_LINEAR;
    const adChanged = next.adId !== currentAdId;
    expect(adChanged).toBe(false);
  });

  it("handles null current ad gracefully", () => {
    let currentAdId: string | undefined = undefined;
    const next = AD_LINEAR;
    const adChanged = next.adId !== currentAdId;
    expect(adChanged).toBe(true);
  });
});