Files
beacon-web/tests/features/packets/payload-renderers.test.tsx

202 lines
9.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PayloadBreakdown } from "../../../src/features/packets/payload-renderers";
import { formatAbsolute, timeAgoMs } from "../../../src/lib/formatters";
import type { ResolvedHop } from "../../../src/types/api";
const tracePayload = {
type: "TRACE",
flags: 0,
pathHashes: ["ab", "cd"],
snrValues: [-5, -8],
};
const resolvedRoute: ResolvedHop[] = [
{ confidence: "high", nodes: [{ id: "n1", publicKey: "abcdef00", name: "Node A" }] },
{ confidence: "ambiguous", nodes: [{ id: "n2", publicKey: "11" }, { id: "n3", publicKey: "22" }] },
];
describe("PayloadBreakdown — trace resolvedRoute overlay", () => {
it("tints each trace-path hash block by its resolved confidence", () => {
render(<PayloadBreakdown payload={tracePayload} resolvedRoute={resolvedRoute} />);
expect(screen.getByText("AB").className).toContain("text-green"); // high
expect(screen.getByText("CD").className).toContain("text-warn"); // ambiguous
});
it("falls back to the plain hash badge when there is no resolvedRoute", () => {
render(<PayloadBreakdown payload={tracePayload} />);
expect(screen.getByText("AB").className).toContain("text-primary");
});
it("makes a single-resolution hop's badge directly clickable (like other packets)", () => {
const onViewNode = vi.fn();
render(<PayloadBreakdown payload={tracePayload} resolvedRoute={resolvedRoute} onViewNode={onViewNode} />);
// hop 0 is high-confidence with one node → the hash badge itself opens that node
fireEvent.click(screen.getByRole("button", { name: "AB" }));
expect(onViewNode).toHaveBeenCalledWith("n1");
// hop 1 is ambiguous (two candidates) → not a direct button; resolved via the popover instead
expect(screen.queryByRole("button", { name: "CD" })).not.toBeInTheDocument();
});
it("renders a '-' placeholder under a hop that has no SNR, so badges stay aligned", () => {
// 3 hashes but only 2 SNR readings → the third hop's sub-line is a placeholder
const payload = { type: "TRACE", flags: 0, pathHashes: ["ab", "cd", "ef"], snrValues: [-5, -8] };
render(<PayloadBreakdown payload={payload} resolvedRoute={resolvedRoute} />);
expect(screen.getByText("-")).toBeInTheDocument();
});
});
describe("PayloadBreakdown — resolved source/destination endpoints", () => {
const envelope = {
type: "TEXT_MESSAGE",
sourceHash: "aa",
destinationHash: "bb",
cipherMac: "0011",
ciphertext: "deadbeef",
decrypted: null,
};
const resolvedSource: ResolvedHop = { confidence: "high", nodes: [{ id: "s1", publicKey: "aa", name: "Alice" }] };
const resolvedDestination: ResolvedHop = { confidence: "high", nodes: [{ id: "d1", publicKey: "bb", name: "Bob" }] };
it("makes the resolved From/To hashes clickable node blocks", () => {
const onViewNode = vi.fn();
render(<PayloadBreakdown payload={envelope} resolvedSource={resolvedSource} resolvedDestination={resolvedDestination} onViewNode={onViewNode} />);
fireEvent.click(screen.getByRole("button", { name: "BB" })); // To → destination node
expect(onViewNode).toHaveBeenCalledWith("d1");
fireEvent.click(screen.getByRole("button", { name: "AA" })); // From → source node
expect(onViewNode).toHaveBeenCalledWith("s1");
});
it("falls back to a plain hash badge when the endpoint did not resolve", () => {
render(<PayloadBreakdown payload={envelope} />);
expect(screen.queryByRole("button", { name: "BB" })).not.toBeInTheDocument();
expect(screen.getByText("BB")).toBeInTheDocument();
});
it("renders an unresolved (none-confidence) endpoint as a non-clickable resolved block", () => {
// the backend sends resolvedSource/Destination as {confidence:"none", nodes:[]} (not omitted)
// when a 1-byte prefix matches nothing — it must not become a clickable node, but still show the hash
const none: ResolvedHop = { confidence: "none", nodes: [] };
render(<PayloadBreakdown payload={envelope} resolvedSource={none} resolvedDestination={none} />);
expect(screen.queryByRole("button", { name: "AA" })).not.toBeInTheDocument();
expect(screen.getByText("AA")).toBeInTheDocument();
expect(screen.getByText("BB")).toBeInTheDocument();
});
it("resolves an ANON_REQUEST destination hash to a node block", () => {
const onViewNode = vi.fn();
const anon = { type: "ANON_REQUEST", destination: 0xbb, ephemeralPubKey: "cc" };
render(<PayloadBreakdown payload={anon} resolvedDestination={resolvedDestination} onViewNode={onViewNode} />);
fireEvent.click(screen.getByRole("button", { name: "0xBB" }));
expect(onViewNode).toHaveBeenCalledWith("d1");
});
});
describe("PayloadBreakdown — DISCOVER_REQ", () => {
// Backend emits DISCOVER as a top-level parsedPayload.type (not nested under CONTROL).
// See beacon-server internal/ingest/packet.go parsedDiscoverReq.
const reqPayload = {
type: "DISCOVER_REQ",
raw: "0b00",
prefixOnly: true,
typeFilter: 0x06, // bits 1 and 2 → ADV_TYPE 1 (ChatNode) + 2 (Repeater)
tag: "0a0b0c0d",
since: 1_700_000_000, // epoch seconds
};
it("decodes the typeFilter bitfield into device-role names", () => {
render(<PayloadBreakdown payload={reqPayload} />);
expect(screen.getByText("ChatNode")).toBeInTheDocument();
expect(screen.getByText("Repeater")).toBeInTheDocument();
});
it("renders the tag as a 0x-prefixed hex value", () => {
render(<PayloadBreakdown payload={reqPayload} />);
expect(screen.getByText("0x0A0B0C0D")).toBeInTheDocument();
});
});
describe("PayloadBreakdown — DISCOVER_RESP", () => {
// See beacon-server internal/ingest/packet.go parsedDiscoverResp.
const respPayload = {
type: "DISCOVER_RESP",
raw: "0b01",
nodeType: 2,
nodeTypeName: "repeater",
requestSnr: -4.5, // responder's node-to-node reading of the request, not observer reception
tag: "0a0b0c0d",
pubKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
pubKeyPrefixOnly: false,
};
it("renders the responder node type and its request SNR", () => {
render(<PayloadBreakdown payload={respPayload} />);
expect(screen.getByText("repeater")).toBeInTheDocument();
expect(screen.getByText(/-4\.50/)).toBeInTheDocument();
});
it("labels a full public key vs. an 8-byte prefix", () => {
const { unmount } = render(<PayloadBreakdown payload={respPayload} />);
expect(screen.getByText(/Public Key/)).toBeInTheDocument();
unmount();
render(<PayloadBreakdown payload={{ ...respPayload, pubKey: "abcdef0011223344", pubKeyPrefixOnly: true }} />);
expect(screen.getByText(/Key Prefix/)).toBeInTheDocument();
});
});
describe("PayloadBreakdown — GROUP_TEXT decrypted channel message", () => {
// Backend GetPacket enrichment nests decrypted:{sender,content,sentAt} (sentAt is epoch ms).
// See beacon-server db/packets.go + internal/ingest/side_effects.go.
// chosen so the ms reading (5:43 p.m.) and the wrong seconds reading (1:13 p.m.) differ
const sentAt = 1_700_001_800_000; // epoch ms
const payload = {
type: "GROUP_TEXT",
channelHash: "ab",
cipherMac: "00112233",
ciphertext: "deadbeef",
decrypted: { sender: "Alice", content: "hello mesh", sentAt },
};
it("renders the decrypted sender and message body from content", () => {
render(<PayloadBreakdown payload={payload} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("hello mesh")).toBeInTheDocument();
});
it("formats sentAt as epoch milliseconds, not seconds", () => {
render(<PayloadBreakdown payload={payload} />);
// <Timestamp> shows a relative label; hovering reveals the absolute time in the tooltip
fireEvent.mouseEnter(screen.getByText(`${timeAgoMs(sentAt)} ago`));
expect(screen.getByRole("tooltip").textContent).toBe(formatAbsolute(sentAt));
// the seconds interpretation (×1000) would be a far-future date
expect(screen.getByRole("tooltip").textContent).not.toBe(formatAbsolute(sentAt * 1000));
});
});
describe("PayloadBreakdown — multi-line message bodies", () => {
// Newlines survive the backend intact (ingest strips only NULs/invalid UTF-8), so the browser's
// default white-space:normal was collapsing them to spaces. jsdom doesn't apply that collapsing,
// so asserting on textContent alone can't catch the bug — the class is what pins it.
const body = "🟠\nDWD aktuell: WARNUNG vor GEWITTER\nDi 17:37 - Di 19:00";
// getByText normalizes whitespace by default, which would defeat the point here
const exact = { normalizer: (s: string) => s };
it.each([
["GROUP_TEXT", { type: "GROUP_TEXT", channelHash: "ab", decrypted: { sender: "Alice", content: body } }],
["GROUP_DATA", { type: "GROUP_DATA", channelHash: "ab", decrypted: { sender: "Alice", content: body } }],
["TEXT_MESSAGE", { type: "TEXT_MESSAGE", cipherMac: "0011", ciphertext: "dead", decrypted: { message: body } }],
["RESPONSE", { type: "RESPONSE", cipherMac: "0011", ciphertext: "dead", decrypted: { content: body } }],
])("preserves linebreaks in a %s body", (_type, payload) => {
render(<PayloadBreakdown payload={payload} />);
expect(screen.getByText(body, exact).className).toContain("whitespace-pre-wrap");
});
it("wraps message text at word boundaries, not mid-word", () => {
const payload = { type: "GROUP_TEXT", channelHash: "ab", decrypted: { sender: "Alice", content: body } };
render(<PayloadBreakdown payload={payload} />);
// break-all is for hex/pubkeys; prose should use break-words
expect(screen.getByText(body, exact).className).toContain("break-words");
expect(screen.getByText(body, exact).className).not.toContain("break-all");
});
});