Files
beacon-web/tests/api/client.test.ts
T
MrAlders0n b8b1f5f27d Feat/scopes routes traces (#7)
* feat: replace getScopeStats with getScopes and update related tests
bug: lastSeen/sentAt was set to string instead of number by mistake

* feat: update timestamp handling and formatting across components

- WS messages carry epoch ms now instead of ISO strings
- new Timestamp component (relative/absolute), used in RouteTable and RouteDetailPanel
- IataChip and ScopeTag chips
- TraceDetailPanel and TraceList for trace drill-down
- tests updated for the timestamp change
2026-06-07 00:19:54 -04:00

291 lines
8.9 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannelMessagesPage, getTraces, getTraceDetail } from "../../src/api/client";
import type { NodeSummary } from "../../src/features/nodes/types";
import type { ObserverSummary } from "../../src/features/observers/types";
import type { ChannelMessage } from "../../src/features/channels/types";
import type { KnownRoute, TraceTagSummary, TraceDetail } from "../../src/types/api";
// 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();
});
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 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"]);
});
});
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/limit", async () => {
const getUrl = mockFetchOnce([route]);
await getKnownRoutesPage({ iata: "YYC", hopCount: 1, cursor: 1234, 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("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", async () => {
mockFetchOnce([{ ...route, lastSeen: 9 }]);
// a page that fills the limit means there may be more — cursor = last (oldest) lastSeen
const page = await getKnownRoutesPage({ limit: 1 });
expect(page.items).toHaveLength(1);
expect(page.hasMore).toBe(true);
expect(page.nextCursor).toBe(9);
});
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("searchKnownRoutes", () => {
it("hits /routes/search with the required iata/from/to", async () => {
const getUrl = mockFetchOnce([]);
await searchKnownRoutes("YYC", "6d", "be");
const url = getUrl();
expect(url).toContain("/routes/search");
expect(url).toContain("iata=YYC");
expect(url).toContain("from=6d");
expect(url).toContain("to=be");
});
});
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");
});
});