fix(frontend): cover platform linking page

This commit is contained in:
Bentlybro
2026-04-30 05:14:26 +01:00
parent 0644ba59c7
commit 7ea8d2a35e
3 changed files with 333 additions and 14 deletions

View File

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

View File

@@ -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(<PlatformLinkPage />);
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(<PlatformLinkPage />);
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(<PlatformLinkPage />);
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(<PlatformLinkPage />);
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(<PlatformLinkPage />);
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(<PlatformLinkPage />);
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(<PlatformLinkPage />);
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");
});
});
});

View File

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