import { beforeEach, describe, expect, test } from "bun:test"; import { EsiClient, EsiHttpError, EsiMarketEstimates, EsiRateLimitError, EsiStabilityError, ESI_COMPATIBILITY_DATE, } from "../src/lib/server/esi"; const asset = (itemId = "9007199254740993", quantity = 2) => ({ item_id: Number(itemId) > Number.MAX_SAFE_INTEGER ? itemId : Number(itemId), type_id: 34, quantity, location_id: 60003760, location_type: "station", location_flag: "Hangar", is_singleton: false, }); function response(body: unknown, headers: Record = {}, status = 200): Response { return new Response(typeof body === "string" ? body : JSON.stringify(body), { status, headers }); } function queueFetch(responses: Response[]) { const calls: { url: string; init: RequestInit }[] = []; const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ url: String(input), init: init ?? {} }); const next = responses.shift(); if (!next) throw new Error(`unexpected request ${String(input)}`); return next; }; return { fetch: fetch as typeof globalThis.fetch, calls }; } describe("central ESI client", () => { beforeEach(() => { expect(ESI_COMPATIBILITY_DATE).toBe("2026-08-18"); }); test("sends pinned and auth headers, fetches pages, and revalidates page one", async () => { const second = [asset("2", 4)]; const queued = queueFetch([ response('[{"item_id":9007199254740993,"type_id":34,"quantity":2,"location_id":60003760,"location_type":"station","location_flag":"Hangar","is_singleton":false}]', { ETag: '"one"', "X-Pages": "2", "Cache-Control": "max-age=0" }), response(second, { ETag: '"two"', "X-Pages": "2", "Cache-Control": "max-age=0" }), new Response(null, { status: 304, headers: { ETag: '"one"', "X-Pages": "2", "Cache-Control": "max-age=60" } }), ]); const client = new EsiClient({ userAgent: "eden-test", fetch: queued.fetch, now: () => 1_000 }); const result = await client.getAssets(42, "secret-token", { userId: "user-a", characterId: 42, scopes: ["esi-assets.read_assets.v1"], }); expect(result.assets.map((item) => item.itemId)).toEqual(["9007199254740993", "2"]); expect(result.assets[0]?.characterId).toBe(42); expect(queued.calls).toHaveLength(3); expect(queued.calls[0]?.url).toBe("https://esi.evetech.net/characters/42/assets?page=1"); const requestHeaders = new Headers(queued.calls[0]?.init.headers); expect(requestHeaders.get("authorization")).toBe("Bearer secret-token"); expect(requestHeaders.get("user-agent")).toBe("eden-test"); expect(requestHeaders.get("x-compatibility-date")).toBe("2026-08-18"); expect(new Headers(queued.calls[2]?.init.headers).get("if-none-match")).toBe('"one"'); }); test("keeps auth contexts private and reuses a 304 body", async () => { let now = 0; const queued = queueFetch([ response([asset("1")], { ETag: '"a"', "X-Pages": "1", "Cache-Control": "max-age=1" }), new Response(null, { status: 304, headers: { ETag: '"a"', "X-Pages": "1", "Cache-Control": "max-age=1" } }), new Response(null, { status: 304, headers: { ETag: '"a"', "X-Pages": "1", "Cache-Control": "max-age=1" } }), new Response(null, { status: 304, headers: { ETag: '"a"', "X-Pages": "1", "Cache-Control": "max-age=1" } }), response([asset("1")], { ETag: '"b"', "X-Pages": "1", "Cache-Control": "max-age=1" }), new Response(null, { status: 304, headers: { ETag: '"b"', "X-Pages": "1", "Cache-Control": "max-age=1" } }), ]); const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => now }); await client.getAssets(7, "a", { userId: "one", scopes: ["assets"] }); now = 2_000; await client.getAssets(7, "a", { userId: "one", scopes: ["assets"] }); await client.getAssets(7, "b", { userId: "two", scopes: ["assets"] }); expect(queued.calls).toHaveLength(6); expect(new Headers(queued.calls[1]?.init.headers).get("if-none-match")).toBe('"a"'); expect(queued.calls[2]?.init.headers).not.toMatchObject({ "If-None-Match": '"a"' }); expect(JSON.stringify(queued.calls)).not.toContain("secret"); }); test("retries transient responses with Retry-After but does not retry 401/403", async () => { const delays: number[] = []; const retryable = queueFetch([ response({ error: "rate" }, { "Retry-After": "2", "X-Ratelimit-Group": "char-asset", "X-Ratelimit-Remaining": "0" }, 429), response([asset("1")], { "X-Pages": "1", "Cache-Control": "max-age=60", ETag: '"x"' }), new Response(null, { status: 304, headers: { "X-Pages": "1", ETag: '"x"', "Cache-Control": "max-age=60" } }), ]); const client = new EsiClient({ userAgent: "test", fetch: retryable.fetch, sleep: async (milliseconds) => { delays.push(milliseconds); }, random: () => 0, now: () => 0, }); await client.getAssets(8, "token", "u"); expect(delays[0]).toBe(2_000); const denied = queueFetch([response({}, {}, 401)]); const deniedClient = new EsiClient({ userAgent: "test", fetch: denied.fetch, now: () => 0 }); await expect(deniedClient.getAssets(8, "token", "u")).rejects.toBeInstanceOf(EsiHttpError); expect(denied.calls).toHaveLength(1); }); test("does not publish changing or incomplete snapshots", async () => { const changing = queueFetch([ response([asset("1")], { "X-Pages": "2", ETag: '"one"' }), response([asset("2")], { "X-Pages": "2", ETag: '"two"' }), response([asset("3")], { "X-Pages": "2", ETag: '"three"' }), ]); const client = new EsiClient({ userAgent: "test", fetch: changing.fetch, now: () => 0 }); await expect(client.getAssets(1, "token", "u")).rejects.toBeInstanceOf(EsiStabilityError); expect(changing.calls).toHaveLength(3); const invalid = queueFetch([response([{ item_id: 1 }], { "X-Pages": "1" })]); const invalidClient = new EsiClient({ userAgent: "test", fetch: invalid.fetch, now: () => 0 }); await expect(invalidClient.getAssets(1, "token", "u")).rejects.toThrow("invalid response"); }); }); describe("ESI market estimates", () => { test("keeps the inherited non-negative market-price policy", async () => { const queued = queueFetch([ response([{ type_id: 34, average_price: -1 }], { ETag: '"negative"', "Cache-Control": "max-age=60" }), ]); const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 1234 }); await expect(new EsiMarketEstimates(client).estimates([34])).rejects.toMatchObject({ code: "invalid_price" }); }); test("uses average prices only and returns cents with retrieval provenance", async () => { const queued = queueFetch([ response([ { type_id: 34, average_price: 12.345, adjusted_price: 999999 }, { type_id: 35, adjusted_price: 100 }, ], { ETag: '"prices"', "Cache-Control": "max-age=60" }), ]); const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 1234 }); const snapshot = await new EsiMarketEstimates(client).estimates([34, 35]); expect(snapshot.prices).toEqual([{ typeId: 34, unitPrice: "1235" }]); expect(snapshot.source).toContain("2026-08-18"); expect(snapshot.asOf).toBe(1234); expect(queued.calls[0]?.url).toBe("https://esi.evetech.net/markets/prices"); }); }); describe("ESI cache and limiter edge cases", () => { test("uses Date, Age, and Expires for freshness", async () => { let now = 1_000_000; const date = new Date(now - 10_000).toUTCString(); const expires = new Date(now + 50_000).toUTCString(); const queued = queueFetch([ response([{ type_id: 34, average_price: 1 }], { Date: date, Age: "2", Expires: expires, ETag: '"p"' }), new Response(null, { status: 304, headers: { Date: new Date(now + 60_000).toUTCString(), ETag: '"p"' } }), ]); const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => now, sleep: async () => {} }); await new EsiMarketEstimates(client).estimates([34]); now += 40_000; await new EsiMarketEstimates(client).estimates([34]); expect(queued.calls).toHaveLength(1); now += 20_000; await new EsiMarketEstimates(client).estimates([34]); expect(queued.calls).toHaveLength(2); }); test("persists new and legacy limiter cooldowns across calls", async () => { let now = 100; const delays: number[] = []; const newer = queueFetch([ response([{ type_id: 34, average_price: 1 }], { "Cache-Control": "max-age=0", "X-Ratelimit-Group": "meta", "X-Ratelimit-Remaining": "0", "Retry-After": "2" }), response([{ type_id: 34, average_price: 1 }], { "Cache-Control": "max-age=60" }), ]); const newClient = new EsiClient({ userAgent: "test", fetch: newer.fetch, now: () => now, sleep: async (ms) => { delays.push(ms); } }); await new EsiMarketEstimates(newClient).estimates([34]); now = 200; await new EsiMarketEstimates(newClient).estimates([34]); expect(delays).toContain(1_900); now = 100; const legacyDelays: number[] = []; const legacy = queueFetch([ response([{ type_id: 34, average_price: 1 }], { "Cache-Control": "max-age=0", "X-ESI-Error-Limit-Remain": "0", "X-ESI-Error-Limit-Reset": "3" }), response([{ type_id: 34, average_price: 1 }], { "Cache-Control": "max-age=60" }), ]); const legacyClient = new EsiClient({ userAgent: "test", fetch: legacy.fetch, now: () => now, sleep: async (ms) => { legacyDelays.push(ms); } }); await new EsiMarketEstimates(legacyClient).estimates([34]); now = 200; await new EsiMarketEstimates(legacyClient).estimates([34]); expect(legacyDelays).toContain(2_900); }); test("does not retry an unbounded server delay beyond the request budget", async () => { const queued = queueFetch([response({}, { "Retry-After": "60" }, 429)]); const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 0, sleep: async () => {} }); await expect(new EsiMarketEstimates(client).estimates([34])).rejects.toBeInstanceOf(EsiRateLimitError); expect(queued.calls).toHaveLength(1); }); test("does not retain no-store bodies or validators", async () => { const body = [asset("1")]; const queued = queueFetch([ response(body, { "Cache-Control": "no-store", ETag: '"nope"', "X-Pages": "1" }), response(body, { "Cache-Control": "no-store", ETag: '"nope"', "X-Pages": "1" }), response(body, { "Cache-Control": "no-store", ETag: '"nope2"', "X-Pages": "1" }), response(body, { "Cache-Control": "no-store", ETag: '"nope2"', "X-Pages": "1" }), ]); const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 0 }); await client.getAssets(10, "token", "u"); await client.getAssets(10, "token", "u"); expect(queued.calls).toHaveLength(4); expect(new Headers(queued.calls[2]?.init.headers).get("if-none-match")).toBeNull(); }); test("cancels discarded retry and error response bodies", async () => { let retryCanceled = 0; const retryResponse = { status: 503, headers: new Headers(), body: { cancel: async () => { retryCanceled += 1; } }, } as unknown as Response; const retryQueue = queueFetch([ retryResponse, response([{ type_id: 34, average_price: 1 }]), ]); const retryClient = new EsiClient({ userAgent: "test", fetch: retryQueue.fetch, now: () => 0, sleep: async () => {} }); await new EsiMarketEstimates(retryClient).estimates([34]); expect(retryCanceled).toBe(1); let errorCanceled = 0; const errorResponse = { status: 401, headers: new Headers(), body: { cancel: async () => { errorCanceled += 1; } }, } as unknown as Response; const errorQueue = queueFetch([errorResponse]); const errorClient = new EsiClient({ userAgent: "test", fetch: errorQueue.fetch, now: () => 0 }); await expect(new EsiMarketEstimates(errorClient).estimates([34])).rejects.toBeInstanceOf(EsiHttpError); expect(errorCanceled).toBe(1); }); });