mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
fix(frontend): cover platform linking page
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user