mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 09:43:11 +00:00
155 lines
5.2 KiB
TypeScript
155 lines
5.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const capturedSpan = vi.hoisted(() => ({ setAttribute: vi.fn() }));
|
|
|
|
vi.mock("@sentry/react", () => ({
|
|
startSpan: vi.fn((options: unknown, callback: (span: unknown) => unknown) =>
|
|
callback(capturedSpan),
|
|
),
|
|
spanToJSON: vi.fn(() => ({ trace_id: "trace-1", span_id: "span-1" })),
|
|
setHttpStatus: vi.fn(),
|
|
}));
|
|
|
|
import * as Sentry from "@sentry/react";
|
|
import { HTTPError } from "ky";
|
|
import { normalizeHttpRoute, traceHttpOperation } from "@/observability/http-transaction";
|
|
|
|
const startSpanMock = vi.mocked(Sentry.startSpan);
|
|
const setHttpStatusMock = vi.mocked(Sentry.setHttpStatus);
|
|
|
|
function lastSpanOptions(): Record<string, unknown> {
|
|
const calls = startSpanMock.mock.calls;
|
|
const [options] = calls[calls.length - 1] ?? [];
|
|
return (options ?? {}) as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
describe("normalizeHttpRoute", () => {
|
|
it("replaces integer path segments with :id", () => {
|
|
expect(normalizeHttpRoute("/api/workorders/9/media")).toBe("/api/workorders/:id/media");
|
|
});
|
|
|
|
it("replaces UUID path segments with :id", () => {
|
|
expect(normalizeHttpRoute("/uplifts/3f2504e0-4f89-11d3-9a0c-0305e82c3301/evidence")).toBe(
|
|
"/uplifts/:id/evidence",
|
|
);
|
|
});
|
|
|
|
it("replaces long hex and opaque token segments with :id", () => {
|
|
expect(normalizeHttpRoute("/files/deadbeefcafe1234")).toBe("/files/:id");
|
|
expect(normalizeHttpRoute("/share/Ab12Cd34Ef56Gh78")).toBe("/share/:id");
|
|
});
|
|
|
|
it("strips query strings and fragments without using their values", () => {
|
|
expect(normalizeHttpRoute("/vendor-portal/dispatches?status=pending#x")).toBe(
|
|
"/vendor-portal/dispatches",
|
|
);
|
|
});
|
|
|
|
it("keeps descriptive segments untouched", () => {
|
|
expect(normalizeHttpRoute("/vendor-operations/insights.csv")).toBe(
|
|
"/vendor-operations/insights.csv",
|
|
);
|
|
});
|
|
|
|
it("uses only the pathname of absolute URLs", () => {
|
|
expect(normalizeHttpRoute("https://api.example.com/workorders/9/media?q=1")).toBe(
|
|
"/workorders/:id/media",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("traceHttpOperation", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("starts a forced http.client transaction with safe attributes", async () => {
|
|
await traceHttpOperation(
|
|
{ method: "get", url: "/workorders/9/media?include=x", entryPoint: "api.apiGet" },
|
|
async () => "ok",
|
|
);
|
|
|
|
const options = lastSpanOptions();
|
|
expect(startSpanMock).toHaveBeenCalledTimes(1);
|
|
expect(options.name).toBe("GET /workorders/:id/media");
|
|
expect(options.op).toBe("http.client");
|
|
expect(options.kind).toBe(2);
|
|
expect(options.forceTransaction).toBe(true);
|
|
expect(options.attributes).toEqual({
|
|
"http.request.method": "GET",
|
|
"http.route": "/workorders/:id/media",
|
|
"code.function": "api.apiGet",
|
|
"operation.type": "http.client",
|
|
});
|
|
});
|
|
|
|
it("adds actor.type only when provided and native span ids as attributes", async () => {
|
|
await traceHttpOperation(
|
|
{
|
|
method: "POST",
|
|
url: "/vendor-portal/dispatches",
|
|
entryPoint: "vendorPortalApi.call",
|
|
actorType: "vendor_link",
|
|
},
|
|
async () => null,
|
|
);
|
|
|
|
expect(lastSpanOptions().attributes).toMatchObject({
|
|
"actor.type": "vendor_link",
|
|
});
|
|
expect(capturedSpan.setAttribute).toHaveBeenCalledWith("trace_id", "trace-1");
|
|
expect(capturedSpan.setAttribute).toHaveBeenCalledWith("span_id", "span-1");
|
|
expect(capturedSpan.setAttribute).toHaveBeenCalledWith("transaction_id", "span-1");
|
|
});
|
|
|
|
it("reports the status of a returned Response", async () => {
|
|
const response = new Response("{}", { status: 201 });
|
|
|
|
await traceHttpOperation({ method: "POST", url: "/x", entryPoint: "fn" }, async () => response);
|
|
|
|
expect(setHttpStatusMock).toHaveBeenCalledTimes(1);
|
|
expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 201);
|
|
});
|
|
|
|
it("exposes a single-shot status reporter for non-Response results", async () => {
|
|
const result = await traceHttpOperation(
|
|
{ method: "GET", url: "/x", entryPoint: "fn" },
|
|
async ({ reportStatus }) => {
|
|
reportStatus(204);
|
|
reportStatus(500);
|
|
return "done";
|
|
},
|
|
);
|
|
|
|
expect(result).toBe("done");
|
|
expect(setHttpStatusMock).toHaveBeenCalledTimes(1);
|
|
expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 204);
|
|
});
|
|
|
|
it("reports the Ky HTTPError response status instead of 500", async () => {
|
|
const httpError = new HTTPError(
|
|
new Response("Unauthorized", { status: 401 }),
|
|
new Request("https://api.example.test/x"),
|
|
{} as never,
|
|
);
|
|
|
|
await expect(
|
|
traceHttpOperation({ method: "GET", url: "/x", entryPoint: "fn" }, async () => {
|
|
throw httpError;
|
|
}),
|
|
).rejects.toThrow();
|
|
|
|
expect(setHttpStatusMock).toHaveBeenCalledTimes(1);
|
|
expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 401);
|
|
});
|
|
|
|
it("reports status 500 when the callback throws", async () => {
|
|
await expect(
|
|
traceHttpOperation({ method: "GET", url: "/x", entryPoint: "fn" }, async () => {
|
|
throw new Error("boom");
|
|
}),
|
|
).rejects.toThrow("boom");
|
|
|
|
expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 500);
|
|
});
|
|
});
|