← back to Kickbacks Ai Upstream

test/hooksE2E.test.ts

297 lines

// Hook-driven end-to-end: spins up the real activate() pipeline (auth →
// portfolio → ad → loopback → CC adapter → timers → CLI sync), then drives
// `kickbacks.test.*` through the mock vscode dispatcher to exercise the
// whole metric/click/state surface. Hermetic: every network call is captured
// by the fetch stub, every command goes through the real registerCommand /
// executeCommand path on the mock. No real install touched.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

// The global setup mock at test/setup.ts already returns testHooksEnabled:
// true, debugEnabled: false. That gives the test hooks a register-and-fire
// path without polluting the developer's ~/.vibe-ads/debug.log.
import { activate, deactivate, __wireForTest } from "../src/extension";
import { makeContext, secrets, _opened, _shown, _openedDocs, commands }
  from "./mocks/vscode";

const AD = {
  ad_id: "ad-e2e", campaign_id: "camp-e2e", title_text: "Acme - faster CI",
  icon_ref: "icon", click_url: "https://acme.example/lp", banner_enabled: false,
};

const mkAdapter = () => ({
  name: "claude-code" as const,
  preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
  version: () => "2.1.143",
  applyPatch: vi.fn(() => ({ ok: true })),
  restore: vi.fn(() => ({ ok: true, restored: true })),
});

/** Fetch stub. Captures every URL+init so individual tests can assert what
 *  the activation chain (or a fired hook) actually sent. Returns:
 *    /v1/portfolio  -> one ad, view_threshold_seconds configurable
 *    /v1/killswitch -> not killed
 *    /v1/earnings   -> $42.00 lifetime / $1.50 today (when signed in)
 *    /v1/metrics    -> 204 — the URL of interest for hook assertions
 *    everything else -> {} 200
 *  Tests mutate `viewThresholdSeconds` and `adOverride` to test variants. */
function stubFetch(opts: { viewThresholdSeconds?: number;
                            adOverride?: typeof AD | null;
                            killed?: boolean } = {}) {
  const calls: { url: string; method: string; body?: unknown;
                 headers: Record<string, string> }[] = [];
  const f = vi.fn(async (input: unknown, init?: { method?: string;
      body?: string; headers?: Record<string, string> }) => {
    const url = typeof input === "string" ? input : String(input);
    const method = (init?.method || "GET").toUpperCase();
    const body = init?.body ? safeJson(init.body) : undefined;
    calls.push({ url, method, body, headers: init?.headers || {} });
    if (url.includes("/v1/auth/extension/poll")) {
      return { ok: true, status: 200, json: async () =>
        ({ access_token: "AT-E2E", refresh_token: "RT-E2E", expires_in: 3600 }) } as Response;
    }
    if (url.includes("/v1/portfolio")) {
      const ad = opts.adOverride === null ? null : (opts.adOverride || AD);
      return { ok: true, status: 200, json: async () => ({
        ttl_seconds: 30,
        view_threshold_seconds: opts.viewThresholdSeconds ?? 15,
        queue_id: "q-1",
        balances: { lifetime_usd: "0.00", today_usd: "0.00", last_updated_ms: 0 },
        ads: ad ? [ad] : [],
      }) } as Response;
    }
    if (url.includes("/v1/killswitch")) {
      return { ok: true, status: 200, json: async () => ({ killed: !!opts.killed }) } as Response;
    }
    if (url.includes("/v1/earnings")) {
      return { ok: true, status: 200, json: async () =>
        ({ lifetime_usd: "42.00", today_usd: "1.50" }) } as Response;
    }
    // /v1/metrics and everything else: 204 OK.
    return { ok: true, status: 204, json: async () => ({}) } as Response;
  });
  vi.stubGlobal("fetch", f);
  return { f, calls };
}

function safeJson(s: string): unknown {
  try { return JSON.parse(s); } catch { return s; }
}

async function boot(opts: { viewThresholdSeconds?: number;
                             adOverride?: typeof AD | null;
                             killed?: boolean } = {}) {
  const home = mkdtempSync(join(tmpdir(), "kb-hooksE2E-"));
  const prevHome = process.env.HOME;
  const prevUser = process.env.USERPROFILE;
  process.env.HOME = home;
  process.env.USERPROFILE = home;
  const adapter = mkAdapter();
  const statusBar = { set: vi.fn(), dispose: vi.fn() };
  __wireForTest({ adapter, statusBar });
  const fetched = stubFetch(opts);
  const ctx = makeContext();
  // Pre-seed an access token via the ext-host secret store. AuthClient's
  // loadCached() reads ctx.secrets first; this gets us "signed in" without
  // driving the broker poll loop and keeps every test ≤ a few hundred ms.
  await ctx.secrets.store("kickbacks.access", "AT-E2E");
  await ctx.secrets.store("kickbacks.refresh", "RT-E2E");
  await ctx.secrets.store("kickbacks.clientId", "CID-E2E");
  await activate(ctx as never);
  return {
    home, adapter, statusBar, ctx, fetched,
    metricsPosts: () => fetched.calls.filter(
      (c) => c.url.endsWith("/v1/metrics")),
    async dispose() {
      await deactivate();
      if (prevHome !== undefined) process.env.HOME = prevHome;
      else delete process.env.HOME;
      if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
      else delete process.env.USERPROFILE;
      try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
    },
  };
}

beforeEach(() => {
  secrets.clear();
  commands._handlers.clear();
  commands._executed.length = 0;
  _opened.length = 0;
  _shown.length = 0;
  _openedDocs.length = 0;
  __wireForTest({});
});

afterEach(() => { vi.unstubAllGlobals(); });

describe("hooks E2E — every kickbacks.test.* command drives the real lifecycle",
  () => {

  it("fireImpressionRendered → one POST /v1/metrics with the loaded ad,"
    + " surface=overlay, X-Vibe-Corr header, ring buffer captures the send",
    async () => {
      const env = await boot();
      try {
        const r = await commands.executeCommand(
          "kickbacks.test.fireImpressionRendered") as { ok: boolean; sent: unknown };
        expect(r.ok).toBe(true);
        const metricPosts = env.metricsPosts();
        expect(metricPosts).toHaveLength(1);
        expect(metricPosts[0].body).toMatchObject({
          event_type: "impression_rendered",
          ad_id: "ad-e2e",
          campaign_id: "camp-e2e",
          surface: "overlay",
          claude_code_version: "2.1.143",
        });
        expect(metricPosts[0].headers["X-Vibe-Corr"]).toBeTypeOf("string");
        const snap = await commands.executeCommand(
          "kickbacks.test.getState") as { lastEvents: { event: string }[] };
        expect(snap.lastEvents.some((e) => e.event === "impression_rendered")).toBe(true);
      } finally { await env.dispose(); }
    });

  it("surface coverage: impression_rendered + impression_viewable + click"
    + " across overlay / banner / codex_overlay / statusline ⇒ 12 POSTs",
    async () => {
      const env = await boot();
      try {
        const surfaces = ["overlay", "banner", "codex_overlay", "statusline"] as const;
        for (const surface of surfaces) {
          await commands.executeCommand(
            "kickbacks.test.fireImpressionRendered", { surface });
          await commands.executeCommand(
            "kickbacks.test.fireImpressionViewable", { surface });
          await commands.executeCommand("kickbacks.test.fireClick", { surface });
        }
        const posts = env.metricsPosts();
        expect(posts).toHaveLength(12);
        for (const surface of surfaces) {
          expect(posts.filter((p) =>
            (p.body as { surface?: string }).surface === surface)).toHaveLength(3);
        }
        const events = posts.map((p) => (p.body as { event_type: string }).event_type);
        expect(events.filter((e) => e === "impression_rendered")).toHaveLength(4);
        expect(events.filter((e) => e === "impression_viewable")).toHaveLength(4);
        expect(events.filter((e) => e === "click")).toHaveLength(4);
      } finally { await env.dispose(); }
    });

  it("fireViewThresholdMet without visibleMs uses portfolio's viewThresholdMs"
    + " (server-controlled credit threshold) on the wire", async () => {
      const env = await boot({ viewThresholdSeconds: 20 });
      try {
        const r = await commands.executeCommand(
          "kickbacks.test.fireViewThresholdMet") as { ok: boolean };
        expect(r.ok).toBe(true);
        const post = env.metricsPosts()[0];
        expect(post.body).toMatchObject({
          event_type: "view_threshold_met",
          visible_ms: 20000,
          view_ms: 20000,
          viewable: true,
          view_pct: 100,
        });
      } finally { await env.dispose(); }
    });

  it("test-hook path BYPASSES ImpressionDedupe — two impression_rendered fires"
    + " ⇒ two POSTs (the production loopback path dedupes; hooks pin this"
    + " distinction so a regression in the controller can't sneak in)",
    async () => {
      const env = await boot();
      try {
        await commands.executeCommand("kickbacks.test.fireImpressionRendered");
        await commands.executeCommand("kickbacks.test.fireImpressionRendered");
        expect(env.metricsPosts()).toHaveLength(2);
      } finally { await env.dispose(); }
    });

  it("getState reflects ad loaded + signedIn + loopback active", async () => {
    const env = await boot();
    try {
      const s = await commands.executeCommand(
        "kickbacks.test.getState") as {
          enabled: boolean; signedIn: boolean; killed: boolean;
          ad: { adId: string } | null; loopback: { port: number } | null;
          viewThresholdMs: number };
      expect(s.enabled).toBe(true);
      expect(s.signedIn).toBe(true);
      expect(s.killed).toBe(false);
      expect(s.ad?.adId).toBe("ad-e2e");
      expect(s.viewThresholdMs).toBe(15000);
      // Loopback comes up async during activate; either active OR null is
      // acceptable for the snapshot — what we pin is "no crash, no garbage".
      if (s.loopback) {
        expect(typeof s.loopback.port).toBe("number");
        expect(s.loopback.port).toBeGreaterThan(0);
      }
    } finally { await env.dispose(); }
  });

  it("clearEventLog mid-session wipes the ring; subsequent fires record again",
    async () => {
      const env = await boot();
      try {
        await commands.executeCommand("kickbacks.test.fireImpressionRendered");
        await commands.executeCommand("kickbacks.test.fireImpressionViewable");
        await commands.executeCommand("kickbacks.test.fireClick");
        await commands.executeCommand("kickbacks.test.clearEventLog");
        const before = await commands.executeCommand(
          "kickbacks.test.getState") as { lastEvents: unknown[] };
        expect(before.lastEvents).toHaveLength(0);
        await commands.executeCommand("kickbacks.test.fireViewTick");
        const after = await commands.executeCommand(
          "kickbacks.test.getState") as { lastEvents: { event: string }[] };
        expect(after.lastEvents).toHaveLength(1);
        expect(after.lastEvents[0].event).toBe("view_tick");
      } finally { await env.dispose(); }
    });

  it("refreshPortfolio re-fetches /v1/portfolio and the snapshot reflects it",
    async () => {
      const env = await boot();
      try {
        const before = env.fetched.calls.filter(
          (c) => c.url.includes("/v1/portfolio")).length;
        const got = await commands.executeCommand(
          "kickbacks.test.refreshPortfolio") as { adId: string } | null;
        expect(got?.adId).toBe("ad-e2e");
        const after = env.fetched.calls.filter(
          (c) => c.url.includes("/v1/portfolio")).length;
        expect(after).toBeGreaterThan(before);
      } finally { await env.dispose(); }
    });

  it("refreshEarnings hits /v1/earnings and returns the parsed payload",
    async () => {
      const env = await boot();
      try {
        const e = await commands.executeCommand(
          "kickbacks.test.refreshEarnings") as
          { lifetimeUsd: string; todayUsd: string } | null;
        expect(e).toEqual({ lifetimeUsd: "42.00", todayUsd: "1.50" });
      } finally { await env.dispose(); }
    });

  it("no ad loaded ⇒ fire returns ok:false (gate still on); adId override"
    + " lets a test inject an arbitrary ad for backend-shape verification",
    async () => {
      const env = await boot({ adOverride: null });
      try {
        const noAd = await commands.executeCommand(
          "kickbacks.test.fireImpressionRendered") as { ok: boolean; reason?: string };
        expect(noAd.ok).toBe(false);
        expect(noAd.reason).toMatch(/no ad/i);
        const withOverride = await commands.executeCommand(
          "kickbacks.test.fireImpressionRendered",
          { adId: "ad-X", campaignId: "camp-X" }) as { ok: boolean };
        expect(withOverride.ok).toBe(true);
        const post = env.metricsPosts()[0];
        expect(post.body).toMatchObject({ ad_id: "ad-X", campaign_id: "camp-X" });
      } finally { await env.dispose(); }
    });
});