Initial Commit
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user