From eb0df61b47d085a67a5d6aef96ee4f5c4ea62eab Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 14 Apr 2026 10:02:16 +0200 Subject: [PATCH] refactor web project structure --- .../app/apps/[appName]/page.tsx | 2 +- .../app/dashboard/components/SessionTable.tsx | 2 +- .../[sessionId]/components/EventTimeline.tsx | 2 +- .../components/InstallProgress.tsx | 2 +- .../lib/__tests__/authenticatedFetch.test.ts | 94 +++++++++++++++++++ 5 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/authenticatedFetch.test.ts diff --git a/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx b/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx index e8168088..f8aebc14 100644 --- a/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx @@ -9,7 +9,7 @@ import { useAuth } from "../../../contexts/AuthContext"; import { useNotifications } from "../../../contexts/NotificationContext"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; -import { getErrorCodeEntry, formatErrorCode } from "@/lib/errorCodeMap"; +import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap"; import { useAdminMode } from "@/hooks/useAdminMode"; import { chartColors } from "../../../components/charts/chartTheme"; diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index 0725c079..efe1cf95 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/navigation"; import { useState, useEffect, useRef } from "react"; import { Session } from "../types"; import { trackEvent } from "@/lib/appInsights"; -import { fuzzyContains } from "@/lib/fuzzy"; +import { fuzzyContains } from "@/utils/fuzzy"; // Column definition for the session table interface ColumnDef { diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx index 520de86f..9109cb4b 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx @@ -3,7 +3,7 @@ import { useState, useMemo } from "react"; import { EnrollmentEvent, Session } from "@/types"; import { normalizeEventDataForDisplay } from "../utils/eventHelpers"; -import { getErrorCodeEntry, formatErrorCode } from "@/lib/errorCodeMap"; +import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap"; interface EventTimelineProps { filteredEvents: EnrollmentEvent[]; diff --git a/src/Web/autopilot-monitor-web/components/InstallProgress.tsx b/src/Web/autopilot-monitor-web/components/InstallProgress.tsx index bc6ea9da..6c8ed13a 100644 --- a/src/Web/autopilot-monitor-web/components/InstallProgress.tsx +++ b/src/Web/autopilot-monitor-web/components/InstallProgress.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { getErrorCodeEntry, formatErrorCode } from "@/lib/errorCodeMap"; +import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap"; interface InstallEvent { timestamp: string; diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/authenticatedFetch.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/authenticatedFetch.test.ts new file mode 100644 index 00000000..3125bc37 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/authenticatedFetch.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { authenticatedFetch, TokenExpiredError } from "../authenticatedFetch"; + +function mockResponse(status: number, body: unknown = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("authenticatedFetch", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("attaches Bearer token and returns the response on success", async () => { + const getToken = vi.fn().mockResolvedValue("tok-1"); + (globalThis.fetch as ReturnType).mockResolvedValueOnce(mockResponse(200, { ok: true })); + + const res = await authenticatedFetch("https://api/x", getToken); + + expect(res.status).toBe(200); + expect(getToken).toHaveBeenCalledTimes(1); + const call = (globalThis.fetch as ReturnType).mock.calls[0]; + const headers = call[1].headers as Headers; + expect(headers.get("Authorization")).toBe("Bearer tok-1"); + }); + + it("throws TokenExpiredError immediately when no token is available", async () => { + const getToken = vi.fn().mockResolvedValue(null); + + await expect(authenticatedFetch("https://api/x", getToken)).rejects.toBeInstanceOf(TokenExpiredError); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("retries with forced fresh token on 401 and returns the retry response", async () => { + const getToken = vi.fn() + .mockResolvedValueOnce("stale") + .mockResolvedValueOnce("fresh"); + const fetchMock = globalThis.fetch as ReturnType; + fetchMock + .mockResolvedValueOnce(mockResponse(401)) + .mockResolvedValueOnce(mockResponse(200, { ok: true })); + + const res = await authenticatedFetch("https://api/x", getToken); + + expect(res.status).toBe(200); + expect(getToken).toHaveBeenCalledTimes(2); + expect(getToken).toHaveBeenNthCalledWith(2, true); // force refresh + expect(fetchMock).toHaveBeenCalledTimes(2); + const retryHeaders = fetchMock.mock.calls[1][1].headers as Headers; + expect(retryHeaders.get("Authorization")).toBe("Bearer fresh"); + }); + + it("throws TokenExpiredError when retry after 401 also returns 401", async () => { + const getToken = vi.fn() + .mockResolvedValueOnce("stale") + .mockResolvedValueOnce("fresh"); + const fetchMock = globalThis.fetch as ReturnType; + fetchMock + .mockResolvedValueOnce(mockResponse(401)) + .mockResolvedValueOnce(mockResponse(401)); + + await expect(authenticatedFetch("https://api/x", getToken)).rejects.toBeInstanceOf(TokenExpiredError); + }); + + it("throws TokenExpiredError when forced refresh returns no token", async () => { + const getToken = vi.fn() + .mockResolvedValueOnce("stale") + .mockResolvedValueOnce(null); + const fetchMock = globalThis.fetch as ReturnType; + fetchMock.mockResolvedValueOnce(mockResponse(401)); + + await expect(authenticatedFetch("https://api/x", getToken)).rejects.toBeInstanceOf(TokenExpiredError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("passes through non-401 error responses without retry", async () => { + const getToken = vi.fn().mockResolvedValue("tok"); + const fetchMock = globalThis.fetch as ReturnType; + fetchMock.mockResolvedValueOnce(mockResponse(500, { error: "boom" })); + + const res = await authenticatedFetch("https://api/x", getToken); + + expect(res.status).toBe(500); + expect(getToken).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +});