shoc-frontend-new/src/test/observability/http-transaction.test.ts
2026-09-03 21:51:40 -03:00

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);
});
});