244 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|