Files
beacon-web/tests/api/client.test.ts
T

667 lines
23 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsSeries, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder, getObserverActivity, isNotFound } from "../../src/api/client";
import type { Feature, Polygon } from "geojson";
import type { NodeSummary } from "../../src/features/nodes/types";
import type { ObserverSummary } from "../../src/features/observers/types";
import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types";
import type { KnownRoute, TraceTagSummary, TraceDetail } from "../../src/types/api";
import { getRateLimitedUntil, noteRequestOk } from "../../src/api/rate-limit";
import { RATE_LIMIT_DEFAULT_MS } from "../../src/lib/constants";
// Capture the URL the client fetches and hand back a canned CursorPage.
function mockFetchOnce(body: unknown): () => string {
let calledUrl = "";
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
calledUrl = url;
return { ok: true, json: async () => body } as Response;
}),
);
return () => calledUrl;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
noteRequestOk();
});
describe("getPackets", () => {
it("forwards plural filters as comma-separated values (routeType 0 survives, scope is encoded)", async () => {
const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false });
await getPackets(["YOW"], { payloadTypes: [2, 4], routeTypes: [0], scopes: ["#bc", "#west"] });
const url = new URL(getUrl());
expect(url.pathname).toContain("/packets");
expect(url.searchParams.get("payloadTypes")).toBe("2,4");
expect(url.searchParams.get("routeTypes")).toBe("0"); // single value 0 survives
expect(url.searchParams.get("scopes")).toBe("#bc,#west");
});
it("omits the filter params when none are given", async () => {
const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false });
await getPackets(["YOW"], { cursor: 100 });
const url = new URL(getUrl());
expect(url.searchParams.has("payloadTypes")).toBe(false);
expect(url.searchParams.has("routeTypes")).toBe(false);
expect(url.searchParams.has("scopes")).toBe(false);
expect(url.searchParams.get("cursor")).toBe("100");
expect(url.searchParams.get("limit")).toBe("50");
expect(url.searchParams.has("hashPrefix")).toBe(false);
});
it("forwards hashPrefix", async () => {
const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false });
await getPackets(["YOW"], { hashPrefix: "6ddf62" });
expect(new URL(getUrl()).searchParams.get("hashPrefix")).toBe("6ddf62");
});
});
describe("getNodesPage", () => {
const node: NodeSummary = {
id: "n1",
publicKey: "pk",
nodeType: 1,
nodeTypeName: "repeater",
name: "Node 1",
lat: 1,
lng: 2,
iatas: [],
};
it("hits /nodes with cursor + limit and returns the full cursor page", async () => {
const getUrl = mockFetchOnce({ items: [node], nextCursor: 4242, hasMore: true });
const page = await getNodesPage(["YYZ"], { cursor: 100 });
const url = getUrl();
expect(url).toContain("/nodes");
expect(url).toContain("iatas=YYZ");
expect(url).toContain("cursor=100");
expect(url).toContain("limit=50");
expect(page).toEqual({ items: [node], nextCursor: 4242, hasMore: true });
});
it("omits cursor on the first page and defaults the limit to 50", async () => {
const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false });
await getNodesPage(undefined);
const url = getUrl();
expect(url).not.toContain("cursor=");
expect(url).toContain("limit=50");
});
it("forwards the pubkeyPrefix search param", async () => {
const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false });
await getNodesPage(["YYZ"], { pubkeyPrefix: "a1b2" });
const url = getUrl();
expect(url).toContain("/nodes");
expect(url).toContain("pubkeyPrefix=a1b2");
});
it("forwards the Nodes-table filters (type maps to typeName, multibyte flags)", async () => {
const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false });
await getNodesPage(["YYZ"], {
type: "repeater",
name: "alpha",
supportsMultibytePaths: "true",
supportsMultibyteTraces: "false",
});
const url = getUrl();
expect(url).toContain("typeName=repeater");
expect(url).toContain("name=alpha");
expect(url).toContain("supportsMultibytePaths=true");
expect(url).toContain("supportsMultibyteTraces=false");
expect(url).not.toContain("type="); // server param is typeName, not type
});
});
describe("getScopes", () => {
it("hits /scopes with no params and returns the scope-name array", async () => {
const getUrl = mockFetchOnce(["#bc", "#west"]);
const scopes = await getScopes();
const url = getUrl();
expect(url).toContain("/scopes");
expect(url).not.toContain("?"); // no query params on the authoritative list
expect(scopes).toEqual(["#bc", "#west"]);
});
it("asks for the selected region's scopes by IATA", async () => {
const getUrl = mockFetchOnce(["#yow"]);
await getScopes(["YOW", "YYZ"]);
expect(new URL(getUrl()).searchParams.get("iatas")).toBe("YOW,YYZ");
});
});
describe("getKnownRoutesPage", () => {
const route: KnownRoute = {
id: 7,
iata: "YYC",
hopCount: 1,
hops: [{ nodeId: "n1", hashBytes: "be" }],
firstSeen: 1,
lastSeen: 2,
};
it("hits /routes, forwards iata/hopCount/cursor/cursorId/limit", async () => {
const getUrl = mockFetchOnce([route]);
await getKnownRoutesPage({ iata: "YYC", hopCount: 1, cursor: { lastSeen: 1234, id: 7 }, limit: 50 });
const url = getUrl();
expect(url).toContain("/routes");
expect(url).toContain("iata=YYC");
expect(url).toContain("hopCount=1");
expect(url).toContain("cursor=1234");
expect(url).toContain("cursorId=7");
expect(url).toContain("limit=50");
});
it("omits cursor on the first page and defaults the limit to 50", async () => {
const getUrl = mockFetchOnce([]);
await getKnownRoutesPage();
const url = getUrl();
expect(url).toContain("/routes");
expect(url).not.toContain("cursor=");
expect(url).toContain("limit=50");
expect(url).not.toContain("iata=");
expect(url).not.toContain("hopCount=");
});
it("wraps a full page: nextCursor is the last route's lastSeen and id", async () => {
mockFetchOnce([{ ...route, lastSeen: 9 }]);
// a page that fills the limit means there may be more; the id breaks lastSeen ties
const page = await getKnownRoutesPage({ limit: 1 });
expect(page.items).toHaveLength(1);
expect(page.hasMore).toBe(true);
expect(page.nextCursor).toEqual({ lastSeen: 9, id: 7 });
});
it("wraps a short page: nextCursor null, hasMore false", async () => {
mockFetchOnce([route]);
const page = await getKnownRoutesPage({ limit: 50 });
expect(page.items).toEqual([route]);
expect(page.hasMore).toBe(false);
expect(page.nextCursor).toBeNull();
});
});
describe("route search", () => {
it("sends the IATA list to /routes/search", async () => {
const getUrl = mockFetchOnce([]);
await searchKnownRoutes(["YYC", "YVR"], "6d", "be");
const url = new URL(getUrl());
expect(url.pathname).toContain("/routes/search");
expect(url.searchParams.get("iatas")).toBe("YYC,YVR");
expect(url.searchParams.get("from")).toBe("6d");
expect(url.searchParams.get("to")).toBe("be");
});
it("omits iatas for a global /routes/cross search", async () => {
const getUrl = mockFetchOnce([]);
await searchCrossIATARoutes(undefined, "6d", "be");
const url = new URL(getUrl());
expect(url.pathname).toContain("/routes/cross");
expect(url.searchParams.has("iatas")).toBe(false);
expect(url.searchParams.get("fromHash")).toBe("6d");
expect(url.searchParams.get("toHash")).toBe("be");
});
});
describe("getChannels", () => {
it.each([1234, "v1:1700000000000123:50"])("sends cursor %s using the compatible parameter", async (cursor) => {
const getUrl = mockFetchOnce({ items: [], hasMore: false });
await getChannels({ cursor });
const params = new URL(getUrl()).searchParams;
expect(params.get(typeof cursor === "string" ? "pageCursor" : "cursor")).toBe(String(cursor));
expect(params.has(typeof cursor === "string" ? "cursor" : "pageCursor")).toBe(false);
});
const channel: ChannelSummary = {
id: 1,
name: "Public",
channelHash: "8b",
lastSeen: 1000,
isHashtag: false,
keyKnown: true,
};
it("sends a single-IATA region as the singular iata param the server honors", async () => {
const page = { items: [channel], nextCursor: 900, hasMore: true };
const getUrl = mockFetchOnce(page);
const channels = await getChannels({ iatas: ["YYZ"] });
const url = new URL(getUrl());
expect(url.pathname).toContain("/channels");
expect(url.searchParams.get("iata")).toBe("YYZ");
expect(url.searchParams.has("iatas")).toBe(false);
expect(channels).toEqual(page);
});
it("keeps the comma-joined iatas param for multi-IATA regions", async () => {
const getUrl = mockFetchOnce({ items: [] });
await getChannels({ iatas: ["YOW", "YYZ"] });
const url = new URL(getUrl());
expect(url.searchParams.get("iatas")).toBe("YOW,YYZ");
expect(url.searchParams.has("iata")).toBe(false);
});
it.each([true, false])("sends keyKnown=%s and omits it when unset", async (keyKnown) => {
const getUrl = mockFetchOnce({ items: [] });
await getChannels({ keyKnown });
expect(new URL(getUrl()).searchParams.get("keyKnown")).toBe(String(keyKnown));
const getUnset = mockFetchOnce({ items: [] });
await getChannels();
expect(new URL(getUnset()).searchParams.has("keyKnown")).toBe(false);
});
it("omits both iata params for all regions", async () => {
const getUrl = mockFetchOnce({ items: [] });
await getChannels();
const url = new URL(getUrl());
expect(url.searchParams.has("iata")).toBe(false);
expect(url.searchParams.has("iatas")).toBe(false);
});
});
describe("getChannelMessagesPage", () => {
const msg: ChannelMessage = {
id: 12,
packetHash: "ab",
channelHash: "cd",
senderName: "alice",
content: "hi",
sentAt: 1000,
};
it("hits /channels/{id}/messages and forwards iatas/cursor/limit", async () => {
const getUrl = mockFetchOnce({ items: [msg] });
await getChannelMessagesPage(3, { iatas: ["YYZ"], cursor: 99, limit: 50 });
const url = getUrl();
expect(url).toContain("/channels/3/messages");
expect(url).toContain("iatas=YYZ");
expect(url).toContain("cursor=99");
expect(url).toContain("limit=50");
});
it("omits cursor on the first page and defaults the limit to 50", async () => {
const getUrl = mockFetchOnce({ items: [] });
await getChannelMessagesPage(3);
const url = getUrl();
expect(url).not.toContain("cursor=");
expect(url).toContain("limit=50");
});
it("wraps a full page: nextCursor is the last (oldest) message id", async () => {
mockFetchOnce({ items: [{ ...msg, id: 5 }] });
const page = await getChannelMessagesPage(3, { limit: 1 });
expect(page.items).toHaveLength(1);
expect(page.hasMore).toBe(true);
expect(page.nextCursor).toBe(5);
});
it("wraps a short page: nextCursor null, hasMore false", async () => {
mockFetchOnce({ items: [msg] });
const page = await getChannelMessagesPage(3, { limit: 50 });
expect(page.items).toEqual([msg]);
expect(page.hasMore).toBe(false);
expect(page.nextCursor).toBeNull();
});
});
describe("getTraces", () => {
const tag: TraceTagSummary = {
traceTag: "04dc2b04",
firstHeardAt: 1,
lastHeardAt: 2,
packetCount: 1,
iataCount: 1,
};
it("hits /traces with comma-joined iatas + scope/limit and returns the bare array", async () => {
const getUrl = mockFetchOnce([tag]);
const traces = await getTraces(["YOW", "YYZ"], { scope: "#bc", limit: 200 });
const url = getUrl();
expect(url).toContain("/traces");
expect(url).toContain("iatas=YOW%2CYYZ");
expect(url).toContain("scope=%23bc");
expect(url).toContain("limit=200");
expect(traces).toEqual([tag]);
});
it("omits iatas for all regions", async () => {
const getUrl = mockFetchOnce([]);
await getTraces(undefined);
const url = getUrl();
expect(url).toContain("/traces");
expect(url).not.toContain("iatas=");
});
});
describe("getTraceDetail", () => {
it("hits /traces/{tag} and returns the detail", async () => {
const detail: TraceDetail = { traceTag: "04dc2b04", packets: [] };
const getUrl = mockFetchOnce(detail);
const result = await getTraceDetail("04dc2b04");
expect(getUrl()).toContain("/traces/04dc2b04");
expect(result).toEqual(detail);
});
});
describe("getObserversPage", () => {
const observer: ObserverSummary = { id: "o1", iata: "YYZ", status: "online" };
it("hits /observers with cursor + limit and returns the full cursor page", async () => {
const getUrl = mockFetchOnce({ items: [observer], nextCursor: 99, hasMore: true });
const page = await getObserversPage(["YYZ"], { cursor: 7 });
const url = getUrl();
expect(url).toContain("/observers");
expect(url).toContain("iatas=YYZ");
expect(url).toContain("cursor=7");
expect(url).toContain("limit=50");
expect(page).toEqual({ items: [observer], nextCursor: 99, hasMore: true });
});
it("forwards the Observers-table filters and omits cursor on the first page", async () => {
const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false });
await getObserversPage(undefined, { status: "online", type: "rak", broker: "b1", name: "north" });
const url = getUrl();
expect(url).not.toContain("cursor=");
expect(url).toContain("status=online");
expect(url).toContain("type=rak");
expect(url).toContain("broker=b1");
expect(url).toContain("name=north");
});
});
describe("stats endpoints", () => {
it("joins the region's IATAs into the iatas param", async () => {
const getUrl = mockFetchOnce({ hours: [] });
await getStatsSeries(0, 1000, ["YOW", "YYZ"]);
const url = new URL(getUrl());
expect(url.pathname).toContain("/stats/series");
expect(url.searchParams.get("iatas")).toBe("YOW,YYZ");
});
it("omits iatas for all regions and still forwards the rest", async () => {
const getUrl = mockFetchOnce([]);
await getTopObservers(undefined, 1700000000000, 15);
const url = new URL(getUrl());
expect(url.searchParams.has("iatas")).toBe(false);
expect(url.searchParams.get("since")).toBe("1700000000000");
expect(url.searchParams.get("limit")).toBe("15");
});
it("hits /stats/top-advertisers with iatas/since/sort/limit", async () => {
const getUrl = mockFetchOnce([]);
await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, "direct", 10);
const url = new URL(getUrl());
expect(url.pathname).toContain("/stats/top-advertisers");
expect(url.searchParams.get("iatas")).toBe("YOW,YYZ");
expect(url.searchParams.get("since")).toBe("1700000000000");
expect(url.searchParams.get("sort")).toBe("direct");
expect(url.searchParams.get("limit")).toBe("10");
});
it("hits /stats/top-talkers with iatas/since/limit", async () => {
const getUrl = mockFetchOnce([]);
await getTopTalkers(["YOW"], 1700000000000, 8);
const url = new URL(getUrl());
expect(url.pathname).toContain("/stats/top-talkers");
expect(url.searchParams.get("iatas")).toBe("YOW");
expect(url.searchParams.get("since")).toBe("1700000000000");
expect(url.searchParams.get("limit")).toBe("8");
});
it("hits /stats/node-types with the region's IATAs", async () => {
const getUrl = mockFetchOnce([{ nodeType: 2, nodeTypeName: "repeater", count: 12 }]);
await getStatsNodeTypes(["YOW", "YYZ"]);
const url = new URL(getUrl());
expect(url.pathname).toContain("/stats/node-types");
expect(url.searchParams.get("iatas")).toBe("YOW,YYZ");
});
it("sends an empty region as region= so the server returns zeros instead of every IATA", async () => {
const getUrl = mockFetchOnce([]);
await getStatsNodeTypes({ region: "empty-region" });
const url = new URL(getUrl());
expect(url.searchParams.get("region")).toBe("empty-region");
expect(url.searchParams.has("iatas")).toBe(false);
});
it("hits /stats/clock-drift with iatas/limit", async () => {
const getUrl = mockFetchOnce([]);
await getClockDrift(["YOW", "YYZ"], 100);
const url = new URL(getUrl());
expect(url.pathname).toContain("/stats/clock-drift");
expect(url.searchParams.get("iatas")).toBe("YOW,YYZ");
expect(url.searchParams.get("limit")).toBe("100");
});
});
describe("getIataBorder", () => {
// this endpoint can 204 (empty body) or send a literal `null`, so mock the status explicitly
function mockStatus(status: number, body: unknown): () => string {
let calledUrl = "";
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
calledUrl = url;
return {
ok: status >= 200 && status < 300,
status,
statusText: "status",
headers: new Headers(),
json: async () => {
if (status === 204) throw new Error("no body to parse");
return body;
},
} as Response;
}),
);
return () => calledUrl;
}
const feature: Feature<Polygon> = {
type: "Feature",
properties: {},
geometry: { type: "Polygon", coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] },
};
it("requests /iatas/{iata}/border", async () => {
const getUrl = mockStatus(200, feature);
await getIataBorder("YOW");
expect(new URL(getUrl()).pathname).toContain("/iatas/YOW/border");
});
it("returns null for a 204 (no border configured) without parsing a body", async () => {
mockStatus(204, undefined);
await expect(getIataBorder("YOW")).resolves.toBeNull();
});
it("treats a literal null body as no border", async () => {
mockStatus(200, null);
await expect(getIataBorder("YOW")).resolves.toBeNull();
});
it("returns the GeoJSON Feature when a border exists", async () => {
mockStatus(200, feature);
await expect(getIataBorder("YOW")).resolves.toEqual(feature);
});
it("surfaces a 429 with the same ApiError shape as request()", async () => {
mockStatus(429, { error: { code: "rate_limited", message: "slow down" } });
const err = await getIataBorder("YOW").catch((e: unknown) => e);
expect(err).toMatchObject({ name: "ApiError", status: 429, code: "rate_limited" });
});
});
describe("error responses", () => {
function mockError(status: number, body: unknown, headers: Record<string, string> = {}) {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status,
statusText: "status",
headers: new Headers(headers),
json: async () => body,
}) as unknown as Response),
);
}
const limited = { error: { code: "rate_limited", message: "slow down" } };
it("a 429 throws an ApiError carrying status, code and retryAfterMs from the header", async () => {
mockError(429, limited, { "Retry-After": "12" });
const err = await getScopes().catch((e: unknown) => e);
expect(err).toMatchObject({ name: "ApiError", status: 429, code: "rate_limited", retryAfterMs: 12_000 });
});
it("a 429 without Retry-After leaves retryAfterMs undefined", async () => {
mockError(429, limited);
const err = await getScopes().catch((e: unknown) => e);
expect(err).toMatchObject({ status: 429, retryAfterMs: undefined });
});
it("a 429 marks the app rate-limited for Retry-After", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000_000);
mockError(429, limited, { "Retry-After": "12" });
await getScopes().catch(() => {});
expect(getRateLimitedUntil()).toBe(1_012_000);
});
it("a 429 without the header uses the default window", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000_000);
mockError(429, limited);
await getScopes().catch(() => {});
expect(getRateLimitedUntil()).toBe(1_000_000 + RATE_LIMIT_DEFAULT_MS);
});
it("the next successful request clears the rate-limited state", async () => {
mockError(429, limited);
await getScopes().catch(() => {});
expect(getRateLimitedUntil()).not.toBeNull();
mockFetchOnce([]);
await getScopes();
expect(getRateLimitedUntil()).toBeNull();
});
it("a plain 500 is not treated as rate limiting", async () => {
mockError(500, { error: { code: "internal", message: "boom" } });
const err = await getScopes().catch((e: unknown) => e);
expect(err).toMatchObject({ status: 500, code: "internal" });
expect(getRateLimitedUntil()).toBeNull();
});
});
describe("getObserverActivity", () => {
it("requests the observer's heard-activity series for a range and bucket interval", async () => {
const getUrl = mockFetchOnce({ range: "24h", interval: "15m", radio: null, payloadTypes: [], points: [] });
await getObserverActivity("obs-1", "24h", "15m");
const url = new URL(getUrl());
expect(url.pathname).toContain("/observers/obs-1/activity");
expect(url.searchParams.get("range")).toBe("24h");
expect(url.searchParams.get("interval")).toBe("15m");
});
});
describe("isNotFound", () => {
it("is true only for a 404 from the API", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status: 404,
statusText: "Not Found",
headers: new Headers(),
json: async () => ({ error: { code: "not_found", message: "no such observer" } }),
})),
);
const err = await getObserverActivity("obs-1", "24h", "15m").catch((e: unknown) => e);
expect(isNotFound(err)).toBe(true);
expect(isNotFound(new Error("network down"))).toBe(false);
});
});
describe("a region with no IATAs", () => {
it("returns empty results without asking the server, which would answer for every IATA", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const empty = { items: [], nextCursor: null, hasMore: false };
await expect(getPackets([])).resolves.toEqual(empty);
await expect(getNodesPage([])).resolves.toEqual(empty);
await expect(getObserversPage([])).resolves.toEqual(empty);
await expect(getChannels({ iatas: [] })).resolves.toEqual(empty);
await expect(getChannelMessagesPage(1, { iatas: [] })).resolves.toEqual(empty);
await expect(getTraces([])).resolves.toEqual([]);
await expect(getScopes([])).resolves.toEqual([]);
expect(fetchSpy).not.toHaveBeenCalled();
});
});