← back to Kickbacks Ai Upstream

test/earnings.test.ts

101 lines

import { describe, it, expect, vi, afterEach } from "vitest";
import { EarningsClient } from "../src/earnings/client";

const okFetch = (async () => ({
  ok: true,
  json: async () => ({ lifetime_usd: "1.20", today_usd: "0.04" }),
})) as unknown as typeof fetch;
const errFetch = (async () => ({ ok: false })) as unknown as typeof fetch;
const badJson = (async () => ({ ok: true, json: async () => ({}) })) as unknown as typeof fetch;

describe("EarningsClient", () => {
  it("returns today + lifetime when signed in", async () => {
    const c = new EarningsClient("http://x", () => "tok", okFetch);
    expect(await c.fetch()).toEqual({ lifetimeUsd: "1.20", todayUsd: "0.04" });
  });
  it("null when signed out (no token)", async () => {
    const c = new EarningsClient("http://x", () => null, okFetch);
    expect(await c.fetch()).toBeNull();
  });
  it("null on non-200", async () => {
    const c = new EarningsClient("http://x", () => "tok", errFetch);
    expect(await c.fetch()).toBeNull();
  });
  it("null when JSON missing fields", async () => {
    const c = new EarningsClient("http://x", () => "tok", badJson);
    expect(await c.fetch()).toBeNull();
  });
});

// audit-2026-06-09 #34: fetch() collapses every failure to null, so the
// caller (earningsRefresh) could not tell a real backend 401 from a network
// blip and falsely flipped authHealthy to "401". fetchDetailed preserves the
// failure KIND: "401" only for a genuine unrecovered HTTP 401.
describe("EarningsClient.fetchDetailed (audit #34)", () => {
  const fetch401 = (async () => ({ ok: false, status: 401 })) as unknown as typeof fetch;
  const fetch500 = (async () => ({ ok: false, status: 500 })) as unknown as typeof fetch;
  const fetchThrow = (async () => { throw new Error("offline"); }) as unknown as typeof fetch;

  it("real 401 with no recovery hook -> outcome '401'", async () => {
    const c = new EarningsClient("http://x", () => "tok", fetch401);
    expect(await c.fetchDetailed()).toEqual({ outcome: "401" });
  });
  it("network error -> 'error', NOT '401'", async () => {
    const c = new EarningsClient("http://x", () => "tok", fetchThrow);
    expect(await c.fetchDetailed()).toEqual({ outcome: "error" });
  });
  it("5xx -> 'error', NOT '401'", async () => {
    const c = new EarningsClient("http://x", () => "tok", fetch500);
    expect(await c.fetchDetailed()).toEqual({ outcome: "error" });
  });
  it("malformed body -> 'error', NOT '401'", async () => {
    const c = new EarningsClient("http://x", () => "tok", badJson);
    expect(await c.fetchDetailed()).toEqual({ outcome: "error" });
  });
  it("401 + successful refresh + ok retry -> 'ok'", async () => {
    let calls = 0;
    const f = (async () => {
      calls++;
      if (calls === 1) return { ok: false, status: 401 };
      return { ok: true,
        json: async () => ({ lifetime_usd: "2.00", today_usd: "0.10" }) };
    }) as unknown as typeof fetch;
    const c = new EarningsClient("http://x", () => "tok", f, async () => true);
    expect(await c.fetchDetailed()).toEqual({ outcome: "ok",
      earnings: { lifetimeUsd: "2.00", todayUsd: "0.10" } });
  });
  it("401 + successful refresh + second 401 -> '401' (really expired)", async () => {
    const c = new EarningsClient("http://x", () => "tok", fetch401, async () => true);
    expect(await c.fetchDetailed()).toEqual({ outcome: "401" });
  });
  it("401 + FAILED refresh -> '401' (session genuinely broken)", async () => {
    const c = new EarningsClient("http://x", () => "tok", fetch401, async () => false);
    expect(await c.fetchDetailed()).toEqual({ outcome: "401" });
  });
});

// audit-2026-06-09 #38: extension.ts passes bare global `fetch` positionally
// (to reach onAuth401), bypassing the timeoutFetch(15000) default — the
// 2A-01 black-holed-connection hang class. The constructor must re-wrap
// exactly that case while leaving injected mocks untouched.
describe("EarningsClient timeout wiring (audit #38)", () => {
  afterEach(() => { vi.unstubAllGlobals(); });

  it("bare global fetch passed positionally is re-wrapped with an abort signal", async () => {
    const inits: (RequestInit | undefined)[] = [];
    vi.stubGlobal("fetch", vi.fn(async (_u: unknown, init?: RequestInit) => {
      inits.push(init);
      return { ok: true,
        json: async () => ({ lifetime_usd: "1.00", today_usd: "0.01" }) };
    }));
    // Mirrors the extension.ts wiring: global fetch as the 3rd positional arg.
    const c = new EarningsClient("http://x", () => "tok", fetch,
      async () => false);
    expect(await c.fetch()).toEqual({ lifetimeUsd: "1.00", todayUsd: "0.01" });
    expect(inits[0]?.signal).toBeInstanceOf(AbortSignal);
    // The bearer header survives the re-wrap.
    expect((inits[0]?.headers as Record<string, string>).authorization)
      .toBe("Bearer tok");
  });
});