← back to Kickbacks Ai Upstream

test/testHooks.test.ts

338 lines

import { describe, it, expect, vi, beforeEach } from "vitest";
import { TestHooks, type TestHooksContext } from "../src/testHooks";
import { MetricsClient } from "../src/metrics/client";
import { PortfolioClient, type PatchAd } from "../src/portfolio/client";
import { EarningsClient } from "../src/earnings/client";

const AD: PatchAd = {
  adId: "ad-1", campaignId: "c-1", adText: "Ramp - 30% faster",
  iconRef: "ramp", iconUrl: "", clickUrl: "https://ramp.com", bannerEnabled: false,
  sessionToken: "test-token",
};

function buildSuite(opts: { ad?: PatchAd | null;
                            signedIn?: boolean;
                            killed?: boolean;
                            viewThresholdMs?: number;
                            onBillableEvent?: () => void } = {}) {
  const calls: { url: string; body: Record<string, unknown>;
                 hdr: Record<string, string> }[] = [];
  const fakeFetch = vi.fn(async (url: string, init: { body: string;
      headers: Record<string, string> }) => {
    calls.push({ url, body: JSON.parse(init.body), hdr: init.headers });
    return { ok: true, status: 200 } as Response;
  });
  const metrics = new MetricsClient("http://b", () => "tok",
    () => "cid", "0.1.0", fakeFetch as never);
  const portfolio = new PortfolioClient("http://b", () => "tok",
    fakeFetch as never);
  const earnings = new EarningsClient("http://b", () => "tok",
    fakeFetch as never);
  const ctx: TestHooksContext = {
    ad: opts.ad === undefined ? AD : opts.ad,
    signedIn: opts.signedIn ?? true,
    killed: opts.killed ?? false,
    ccVersion: "2.1.143",
    viewThresholdMs: opts.viewThresholdMs ?? 15000,
    loopback: { port: 12345, base: "http://127.0.0.1:12345/vibe-ads/tok" },
  };
  const hooks = new TestHooks(metrics, portfolio, earnings, () => ctx,
    opts.onBillableEvent ?? null);
  return { hooks, calls, fakeFetch, ctx };
}

describe("TestHooks", () => {
  beforeEach(() => { vi.clearAllMocks(); });

  it("fires impression_rendered through MetricsClient with default surface", async () => {
    const { hooks, calls } = buildSuite();
    const r = await hooks.fireImpressionRendered();
    expect(r.ok).toBe(true);
    expect(r.sent?.event).toBe("impression_rendered");
    expect(r.sent?.surface).toBe("overlay");
    expect(calls).toHaveLength(1);
    expect(calls[0].url).toBe("http://b/v1/metrics");
    expect(calls[0].body).toMatchObject({
      event_type: "impression_rendered",
      ad_id: "ad-1",
      campaign_id: "c-1",
      claude_code_version: "2.1.143",
      surface: "overlay",
    });
    expect(typeof calls[0].body.nonce).toBe("string");
    expect(calls[0].hdr.authorization).toBe("Bearer tok");
    expect(calls[0].hdr["X-Vibe-Corr"]).toBeTypeOf("string");
  });

  it("honors surface override and accepts adId/campaignId when no ad loaded",
    async () => {
      const { hooks, calls } = buildSuite({ ad: null });
      const r = await hooks.fireClick({ surface: "banner",
        adId: "x1", campaignId: "y1" });
      expect(r.ok).toBe(true);
      expect(calls[0].body).toMatchObject({
        event_type: "click", ad_id: "x1", campaign_id: "y1", surface: "banner",
      });
    });

  it("returns ok:false when no ad and no override is supplied", async () => {
    const { hooks, calls } = buildSuite({ ad: null });
    const r = await hooks.fireImpressionRendered();
    expect(r.ok).toBe(false);
    expect(r.reason).toMatch(/no ad/i);
    expect(calls).toHaveLength(0);
  });

  it("fireViewThresholdMet sets viewable/viewPct/viewMs and defaults visibleMs"
    + " to the configured threshold", async () => {
    const { hooks, calls } = buildSuite({ viewThresholdMs: 20000 });
    const r = await hooks.fireViewThresholdMet();
    expect(r.ok).toBe(true);
    expect(calls[0].body).toMatchObject({
      event_type: "view_threshold_met",
      viewable: true,
      view_pct: 100,
      visible_ms: 20000,
      view_ms: 20000,
      surface: "overlay",
    });
  });

  it("fireImpressionViewable marks viewable:true / viewPct:100", async () => {
    const { hooks, calls } = buildSuite();
    await hooks.fireImpressionViewable({ visibleMs: 7000 });
    expect(calls[0].body).toMatchObject({
      event_type: "impression_viewable",
      viewable: true,
      view_pct: 100,
      visible_ms: 7000,
    });
  });

  it("records each successful send in the ring and surfaces it via getState",
    async () => {
      const { hooks } = buildSuite();
      await hooks.fireImpressionRendered();
      await hooks.fireImpressionViewable({ surface: "banner" });
      await hooks.fireClick({ surface: "codex_overlay" });
      const state = hooks.getState();
      expect(state.lastEvents).toHaveLength(3);
      expect(state.lastEvents.map((e) => e?.event)).toEqual([
        "impression_rendered", "impression_viewable", "click",
      ]);
      expect(state.ad?.adId).toBe("ad-1");
      expect(state.loopback?.port).toBe(12345);
      hooks.clearEventLog();
      expect(hooks.getState().lastEvents).toHaveLength(0);
    });

  it("uses caller-supplied corr verbatim when provided", async () => {
    const { hooks, calls } = buildSuite();
    await hooks.fireClick({ corr: "fixed.corr.abc" });
    expect(calls[0].hdr["X-Vibe-Corr"]).toBe("fixed.corr.abc");
  });

  it("fireViewTick sends a heartbeat event with no viewable/viewPct fields"
    + " and threads visibleMs through", async () => {
    const { hooks, calls } = buildSuite();
    const r = await hooks.fireViewTick({ visibleMs: 5000 });
    expect(r.ok).toBe(true);
    expect(r.sent?.event).toBe("view_tick");
    expect(calls[0].body).toMatchObject({
      event_type: "view_tick", visible_ms: 5000, surface: "overlay",
    });
    expect("viewable" in calls[0].body).toBe(false);
    expect("view_pct" in calls[0].body).toBe(false);
  });

  it("fireViewTick bypasses ImpressionDedupe — three fires => three POSTs",
    async () => {
      const { hooks, calls } = buildSuite();
      await hooks.fireViewTick();
      await hooks.fireViewTick();
      await hooks.fireViewTick();
      expect(calls).toHaveLength(3);
      expect(calls.every((c) => c.body.event_type === "view_tick")).toBe(true);
    });

  it("schedules earnings refresh after successful billable test-hook events",
    async () => {
      const onBillableEvent = vi.fn();
      const { hooks } = buildSuite({ onBillableEvent });
      await hooks.fireImpressionRendered();
      await hooks.fireViewTick();
      expect(onBillableEvent).not.toHaveBeenCalled();
      await hooks.fireImpressionViewable();
      await hooks.fireViewThresholdMet();
      await hooks.fireErrorImpression();
      await hooks.fireClick();
      expect(onBillableEvent).toHaveBeenCalledTimes(4);
    });

  it("refreshPortfolio calls PortfolioClient.fetchPortfolio with ccVersion"
    + " and surfaces the new ad", async () => {
    const ad2: PatchAd = { adId: "ad-2", campaignId: "c-2",
      adText: "New ad text", iconRef: "x", iconUrl: "", clickUrl: "https://x", bannerEnabled: false,
      sessionToken: "test-token-2" };
    // Override fetch on the suite to return a portfolio body with ad2.
    const ringCalls: { url: string; init: { headers: Record<string, string> } }[] = [];
    const portfolioBody = {
      ttl_seconds: 30,
      view_threshold_seconds: 25,
      ads: [{ ad_id: ad2.adId, campaign_id: ad2.campaignId, title_text: ad2.adText,
        icon_ref: ad2.iconRef, click_url: ad2.clickUrl, banner_enabled: false }],
    };
    const fakeFetch = vi.fn(async (url: string, init: { headers: Record<string, string> }) => {
      ringCalls.push({ url, init });
      if (url.includes("/v1/portfolio")) {
        return {
          ok: true, status: 200,
          json: async () => portfolioBody,
        } as unknown as Response;
      }
      return { ok: true, status: 200 } as Response;
    });
    const { MetricsClient: M } = await import("../src/metrics/client");
    const { PortfolioClient: P } = await import("../src/portfolio/client");
    const { EarningsClient: E } = await import("../src/earnings/client");
    const metrics = new M("http://b", () => "tok",
      () => "cid", "0.1.0", fakeFetch as never);
    const portfolio = new P("http://b", () => "tok", fakeFetch as never);
    const earnings = new E("http://b", () => "tok", fakeFetch as never);
    const ctx: TestHooksContext = { ad: AD, signedIn: true, killed: false,
      ccVersion: "2.1.143", viewThresholdMs: 15000, loopback: null };
    const hooks = new TestHooks(metrics, portfolio, earnings, () => ctx);
    const got = await hooks.refreshPortfolio();
    expect(got?.adId).toBe("ad-2");
    expect(ringCalls[0].url).toContain("/v1/portfolio?claude_code_version=2.1.143");
    expect(ringCalls[0].init.headers.authorization).toBe("Bearer tok");
  });

  it("refreshEarnings returns whatever EarningsClient.fetch returns",
    async () => {
      const fakeFetch = vi.fn(async (url: string) => {
        if (url.includes("/v1/earnings")) {
          return {
            ok: true, status: 200,
            json: async () => ({ lifetime_usd: "12.34", today_usd: "1.50" }),
          } as unknown as Response;
        }
        return { ok: true, status: 200 } as Response;
      });
      const { MetricsClient: M } = await import("../src/metrics/client");
      const { PortfolioClient: P } = await import("../src/portfolio/client");
      const { EarningsClient: E } = await import("../src/earnings/client");
      const hooks = new TestHooks(
        new M("http://b", () => "tok", () => "cid", "0.1.0", fakeFetch as never),
        new P("http://b", () => "tok", fakeFetch as never),
        new E("http://b", () => "tok", fakeFetch as never),
        () => ({ ad: AD, signedIn: true, killed: false, ccVersion: "v",
          viewThresholdMs: 15000, loopback: null }));
      const earnings = await hooks.refreshEarnings();
      expect(earnings).toEqual({ lifetimeUsd: "12.34", todayUsd: "1.50" });
    });

  it("clearEventLog wipes the ring; subsequent fires record again", async () => {
    const { hooks } = buildSuite();
    await hooks.fireImpressionRendered();
    await hooks.fireImpressionViewable();
    await hooks.fireClick();
    expect(hooks.getState().lastEvents).toHaveLength(3);
    hooks.clearEventLog();
    expect(hooks.getState().lastEvents).toHaveLength(0);
    await hooks.fireViewTick();
    const ring = hooks.getState().lastEvents;
    expect(ring).toHaveLength(1);
    expect(ring[0]?.event).toBe("view_tick");
  });

  it("registerCommands wires every kickbacks.test.* id exactly once and"
    + " each handler invokes the corresponding TestHooks method", async () => {
    const { hooks } = buildSuite();
    // Hand-rolled minimal mock so the spy assertions stay focused on this
    // contract; reuses the real vscode mock's dispatching pattern.
    const handlers = new Map<string, (...a: unknown[]) => unknown>();
    const subs: { dispose(): void }[] = [];
    const fakeCtx = { subscriptions: subs } as unknown as
      Parameters<typeof hooks.registerCommands>[0];
    const fakeVscode = await import("./mocks/vscode");
    // Reset shared handler map (it's module-scoped on the mock).
    fakeVscode.commands._handlers.clear();
    fakeVscode.commands._executed.length = 0;
    // Spy on the mock's registerCommand so we capture insertion order.
    const ids: string[] = [];
    const origReg = fakeVscode.commands.registerCommand.bind(fakeVscode.commands);
    fakeVscode.commands.registerCommand = function (id, h) {
      ids.push(id);
      handlers.set(id, h);
      return origReg(id, h);
    };
    try {
      hooks.registerCommands(fakeCtx);
      const expected = [
        "kickbacks.test.fireImpressionRendered",
        "kickbacks.test.fireImpressionViewable",
        "kickbacks.test.fireViewTick",
        "kickbacks.test.fireViewThresholdMet",
        "kickbacks.test.fireErrorImpression",
        "kickbacks.test.fireClick",
        "kickbacks.test.refreshPortfolio",
        "kickbacks.test.refreshEarnings",
        "kickbacks.test.getState",
        "kickbacks.test.clearEventLog",
      ];
      expect(ids).toEqual(expected);
      // Each id is registered exactly once.
      const counts = new Map<string, number>();
      for (const id of ids) counts.set(id, (counts.get(id) || 0) + 1);
      expect([...counts.values()].every((n) => n === 1)).toBe(true);
      // ExtensionContext gets a disposable per command.
      expect(subs.length).toBe(expected.length);
      // Invoking a fire command actually drives the hook (one POST queued).
      const r = await fakeVscode.commands.executeCommand(
        "kickbacks.test.fireClick", { surface: "banner" }) as { ok: boolean };
      expect(r.ok).toBe(true);
      expect(hooks.getState().lastEvents.some(
        (e) => e?.event === "click" && e.surface === "banner")).toBe(true);
      // getState is reachable via the dispatcher and returns a snapshot.
      const snap = await fakeVscode.commands.executeCommand(
        "kickbacks.test.getState") as { enabled: boolean };
      expect(snap.enabled).toBe(true);
    } finally {
      fakeVscode.commands.registerCommand = origReg;
    }
  });
});

// Separate suite that disables the gate via a per-suite mock.
describe("TestHooks gated off", () => {
  beforeEach(() => { vi.resetModules(); });
  it("returns ok:false and skips the network when test hooks are disabled",
    async () => {
      vi.doMock("../src/log", () => ({
        debugEnabled: () => false, dlog: () => {}, dlogRaw: () => {},
        codexEnabled: () => false, testHooksEnabled: () => false,
        LOG_PATH: "/tmp/test-log",
      }));
      const { TestHooks: GatedHooks } = await import("../src/testHooks");
      const { MetricsClient: M } = await import("../src/metrics/client");
      const { PortfolioClient: P } = await import("../src/portfolio/client");
      const { EarningsClient: E } = await import("../src/earnings/client");
      const calls: unknown[] = [];
      const f = vi.fn(async () => {
        calls.push("hit"); return { ok: true, status: 200 } as Response;
      });
      const hooks = new GatedHooks(
        new M("http://b", () => "tok", () => "cid", "0.1.0", f as never),
        new P("http://b", () => "tok", f as never),
        new E("http://b", () => "tok", f as never),
        () => ({ ad: AD, signedIn: true, killed: false,
                 ccVersion: "v", viewThresholdMs: 15000, loopback: null }));
      const r = await hooks.fireImpressionRendered();
      expect(r.ok).toBe(false);
      expect(r.reason).toMatch(/disabled/);
      expect(calls).toHaveLength(0);
      vi.doUnmock("../src/log");
    });
});