Files
eden/tests/esi.test.ts
T

244 lines
12 KiB
TypeScript

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<string, string> = {}, 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);
});
});