From 7ea8d2a35e72c8e6aba9a0949ffb99e369473835 Mon Sep 17 00:00:00 2001 From: Bentlybro Date: Thu, 30 Apr 2026 05:14:26 +0100 Subject: [PATCH] fix(frontend): cover platform linking page --- .../link/[token]/__tests__/helpers.test.ts | 43 +++ .../link/[token]/__tests__/page.test.tsx | 273 ++++++++++++++++++ .../link/[token]/usePlatformLinkingPage.ts | 31 +- 3 files changed, 333 insertions(+), 14 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/helpers.test.ts create mode 100644 autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/page.test.tsx diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/helpers.test.ts b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/helpers.test.ts new file mode 100644 index 0000000000..b254139ca2 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/helpers.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; + +import { LinkType } from "@/app/api/__generated__/models/linkType"; +import { + getLoginRedirect, + getPlatformDisplayName, + isUserLink, + TOKEN_PATTERN, +} from "../helpers"; + +describe("platform linking helpers", () => { + test("accepts URL-safe token values up to the backend limit", () => { + expect(TOKEN_PATTERN.test("abc_123-XYZ")).toBe(true); + expect(TOKEN_PATTERN.test("a".repeat(64))).toBe(true); + }); + + test("rejects malformed token values", () => { + expect(TOKEN_PATTERN.test("")).toBe(false); + expect(TOKEN_PATTERN.test("a".repeat(65))).toBe(false); + expect(TOKEN_PATTERN.test("abc.123")).toBe(false); + expect(TOKEN_PATTERN.test("abc/123")).toBe(false); + }); + + test("formats known platforms and falls back for unknown values", () => { + expect(getPlatformDisplayName("discord")).toBe("Discord"); + expect(getPlatformDisplayName("GITHUB")).toBe("GitHub"); + expect(getPlatformDisplayName("matrix")).toBe("matrix"); + expect(getPlatformDisplayName(null)).toBe("chat platform"); + }); + + test("builds the login redirect for valid and missing tokens", () => { + expect(getLoginRedirect("token-123")).toBe( + "/login?next=%2Flink%2Ftoken-123", + ); + expect(getLoginRedirect(null)).toBe("/login?next=%2F"); + }); + + test("identifies user link token types", () => { + expect(isUserLink(LinkType.USER)).toBe(true); + expect(isUserLink(LinkType.SERVER)).toBe(false); + expect(isUserLink(undefined)).toBe(false); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/page.test.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/page.test.tsx new file mode 100644 index 0000000000..c4f36c4fa1 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/page.test.tsx @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { LinkType } from "@/app/api/__generated__/models/linkType"; +import { + getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200, + getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler401, + getPostPlatformLinkingConfirmAServerLinkTokenUserMustBeAuthenticatedMockHandler200, + getPostPlatformLinkingConfirmAServerLinkTokenUserMustBeAuthenticatedMockHandler401, + getPostPlatformLinkingConfirmAUserLinkTokenUserMustBeAuthenticatedMockHandler200, +} from "@/app/api/__generated__/endpoints/platform-linking/platform-linking.msw"; +import { server } from "@/mocks/mock-server"; +import { + fireEvent, + render, + screen, + waitFor, +} from "@/tests/integrations/test-utils"; +import PlatformLinkPage from "../page"; + +const mockUseParams = vi.hoisted(() => vi.fn()); +const mockUseSearchParams = vi.hoisted(() => vi.fn()); +const mockUseSupabase = vi.hoisted(() => vi.fn()); +const mockLogOut = vi.hoisted(() => vi.fn()); + +vi.mock("next/navigation", () => ({ + useParams: mockUseParams, + usePathname: () => "/link/token-123", + useRouter: () => ({ + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + push: vi.fn(), + refresh: vi.fn(), + replace: vi.fn(), + }), + useSearchParams: mockUseSearchParams, +})); + +vi.mock("@/lib/supabase/hooks/useSupabase", () => ({ + useSupabase: mockUseSupabase, +})); + +function authenticate() { + mockUseSupabase.mockReturnValue({ + user: { + id: "user-1", + email: "owner@example.com", + app_metadata: {}, + user_metadata: {}, + aud: "authenticated", + created_at: "2026-01-01T00:00:00.000Z", + }, + isLoggedIn: true, + isUserLoading: false, + logOut: mockLogOut, + supabase: {}, + }); +} + +function setRoute(token = "token-123", platform = "discord") { + mockUseParams.mockReturnValue({ token }); + mockUseSearchParams.mockReturnValue(new URLSearchParams({ platform })); +} + +beforeEach(() => { + vi.clearAllMocks(); + authenticate(); + setRoute(); + Object.defineProperty(window, "location", { + configurable: true, + value: { href: "http://localhost/link/token-123" }, + }); +}); + +describe("PlatformLinkPage", () => { + test("shows a malformed-token error without fetching link info", () => { + const infoHandler = vi.fn(); + setRoute("bad.token"); + server.use( + getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200(() => { + infoHandler(); + return { + platform: "DISCORD", + link_type: LinkType.SERVER, + server_name: "Guild", + }; + }), + ); + + render(); + + expect(screen.getByText(/setup link is malformed/i)).toBeDefined(); + expect(infoHandler).not.toHaveBeenCalled(); + }); + + test("asks unauthenticated users to sign in without fetching link info", () => { + const infoHandler = vi.fn(); + mockUseSupabase.mockReturnValue({ + user: null, + isLoggedIn: false, + isUserLoading: false, + logOut: mockLogOut, + supabase: {}, + }); + server.use( + getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200(() => { + infoHandler(); + return { + platform: "DISCORD", + link_type: LinkType.SERVER, + server_name: "Guild", + }; + }), + ); + + render(); + + expect( + screen.getByRole("heading", { name: /sign in to continue/i }), + ).toBeDefined(); + expect( + screen.getByRole("link", { name: /^sign in$/i }).getAttribute("href"), + ).toBe("/login?next=%2Flink%2Ftoken-123"); + expect(infoHandler).not.toHaveBeenCalled(); + }); + + test("loads server link details and confirms the server link", async () => { + server.use( + getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200({ + platform: "DISCORD", + link_type: LinkType.SERVER, + server_name: "Builders Guild", + }), + getPostPlatformLinkingConfirmAServerLinkTokenUserMustBeAuthenticatedMockHandler200( + { + success: true, + link_type: LinkType.SERVER, + platform: "DISCORD", + platform_server_id: "server-1", + server_name: "Builders Guild", + }, + ), + ); + + render(); + + expect( + await screen.findByRole("heading", { + name: /set up autopilot for builders guild/i, + }), + ).toBeDefined(); + expect(screen.getByText(/signed in as owner@example.com/i)).toBeDefined(); + + fireEvent.click( + screen.getByRole("button", { name: /connect discord to autogpt/i }), + ); + + expect( + await screen.findByRole("heading", { name: /autopilot is ready/i }), + ).toBeDefined(); + expect(screen.getByText(/builders guild/i)).toBeDefined(); + }); + + test("loads user link details and confirms the user link endpoint", async () => { + let serverConfirmCalls = 0; + let userConfirmCalls = 0; + server.use( + getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200({ + platform: "TELEGRAM", + link_type: LinkType.USER, + server_name: null, + }), + getPostPlatformLinkingConfirmAServerLinkTokenUserMustBeAuthenticatedMockHandler200( + () => { + serverConfirmCalls += 1; + return { + success: true, + link_type: LinkType.SERVER, + platform: "TELEGRAM", + platform_server_id: "server-1", + server_name: null, + }; + }, + ), + getPostPlatformLinkingConfirmAUserLinkTokenUserMustBeAuthenticatedMockHandler200( + () => { + userConfirmCalls += 1; + return { + success: true, + link_type: LinkType.USER, + platform: "TELEGRAM", + platform_user_id: "platform-user-1", + }; + }, + ), + ); + + render(); + + expect( + await screen.findByRole("heading", { name: /link your telegram dms/i }), + ).toBeDefined(); + + fireEvent.click( + screen.getByRole("button", { name: /connect my telegram dms/i }), + ); + + expect( + await screen.findByRole("heading", { name: /autopilot is ready/i }), + ).toBeDefined(); + expect(userConfirmCalls).toBe(1); + expect(serverConfirmCalls).toBe(0); + }); + + test("shows an expired-link message when info loading fails", async () => { + server.use( + getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler401(), + ); + + render(); + + expect( + await screen.findByText(/couldn't load setup details/i), + ).toBeDefined(); + }); + + test("shows backend detail when confirmation fails", async () => { + server.use( + getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200({ + platform: "DISCORD", + link_type: LinkType.SERVER, + server_name: "Builders Guild", + }), + getPostPlatformLinkingConfirmAServerLinkTokenUserMustBeAuthenticatedMockHandler401( + { + detail: "This setup link was already used.", + }, + ), + ); + + render(); + + fireEvent.click( + await screen.findByRole("button", { + name: /connect discord to autogpt/i, + }), + ); + + expect( + await screen.findByText(/this setup link was already used/i), + ).toBeDefined(); + }); + + test("signs out and redirects back through login when switching account", async () => { + mockLogOut.mockResolvedValue(undefined); + server.use( + getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200({ + platform: "DISCORD", + link_type: LinkType.SERVER, + server_name: "Builders Guild", + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /not you/i })); + + await waitFor(() => { + expect(mockLogOut).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe("/login?next=%2Flink%2Ftoken-123"); + }); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/usePlatformLinkingPage.ts b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/usePlatformLinkingPage.ts index 32d83a11bc..af78efc99a 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/usePlatformLinkingPage.ts +++ b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/usePlatformLinkingPage.ts @@ -43,15 +43,7 @@ export function usePlatformLinkingPage() { isError: isInfoError, } = useGetPlatformLinkingGetDisplayInfoForALinkToken(token ?? "", { query: { - // Endpoint requires auth — gate the query on a logged-in user so we - // don't fire a guaranteed-401 request before the user signs in. enabled: Boolean(token) && Boolean(user), - // Narrow the discriminated response union so `info` is typed as the - // success payload (LinkTokenInfoResponse) rather than the 200 ∪ 401 ∪ - // 422 union. The literal `=== 200` check is what lets TS narrow the - // discriminated union — `okData` doesn't distribute correctly across - // multiple error-shape variants, and a 2xx range check (`>=200 && <300`) - // wouldn't narrow at all. GET-only success here is 200. select: (res) => (res && res.status === 200 ? res.data : undefined), retry: false, }, @@ -82,10 +74,6 @@ export function usePlatformLinkingPage() { function handleLink() { if (!token || !info) return; - // Fire-and-forget: React Query surfaces the outcome via - // `mutation.isPending | isSuccess | isError`, which `resolveStatus` - // consumes. Using `mutate` over `mutateAsync` avoids the unhandled - // promise rejection that would otherwise fire on API failure. mutation.mutate({ token }); } @@ -190,7 +178,22 @@ const DEFAULT_MUTATION_ERROR = function extractDetail(error: unknown): string | undefined { if (!error || typeof error !== "object") return undefined; - const maybeData = (error as { data?: { detail?: unknown } }).data; - const detail = maybeData?.detail; + const responseDetail = extractDetailFromPayload( + (error as { response?: unknown }).response, + ); + if (responseDetail) return responseDetail; + + const dataDetail = extractDetailFromPayload( + (error as { data?: unknown }).data, + ); + if (dataDetail) return dataDetail; + + const message = (error as { message?: unknown }).message; + return typeof message === "string" ? message : undefined; +} + +function extractDetailFromPayload(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") return undefined; + const detail = (payload as { detail?: unknown }).detail; return typeof detail === "string" ? detail : undefined; }