mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
PR #1868 conflated "has workspace" with "completed onboarding" — restore `onboarded_at` as the single signal, and route invited users through a dedicated /invitations page before they ever see onboarding. - Backend: CreateWorkspace + AcceptInvitation atomically set onboarded_at alongside the member insert, establishing the invariant "member row exists ↔ onboarded_at != null" at the DB layer. - Migration 065: one-shot backfill closes the dirty rows produced by PR #1868 (users with a workspace but onboarded_at == null). - Entry points (web callback, login, desktop App): if onboarded_at is null, look up pending invitations by email and route to the new batch /invitations page; otherwise the resolver picks workspace / new-workspace as before. - OnboardingPage: stops bouncing on hasWorkspaces; only hasOnboarded bounces. Unblocks the user from completing Step 3 (workspace creation) → Steps 4 / 5. - StarterContentPrompt: only shows when the user is the solo member of the workspace, so invited users never get prompted to import starter content into someone else's workspace. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
185 lines
5.5 KiB
TypeScript
185 lines
5.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, waitFor } from "@testing-library/react";
|
|
import { paths } from "@multica/core/paths";
|
|
|
|
const {
|
|
mockPush,
|
|
mockSearchParams,
|
|
mockLoginWithGoogle,
|
|
mockListWorkspaces,
|
|
mockListMyInvitations,
|
|
mockSetQueryData,
|
|
} = vi.hoisted(() => ({
|
|
mockPush: vi.fn(),
|
|
mockSearchParams: new URLSearchParams(),
|
|
mockLoginWithGoogle: vi.fn(),
|
|
mockListWorkspaces: vi.fn(),
|
|
mockListMyInvitations: vi.fn(),
|
|
mockSetQueryData: vi.fn(),
|
|
}));
|
|
|
|
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
|
|
id: "user-1",
|
|
name: "Test",
|
|
email: "test@multica.ai",
|
|
avatar_url: null,
|
|
onboarded_at: null,
|
|
onboarding_questionnaire: {},
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
...overrides,
|
|
});
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({ push: mockPush }),
|
|
useSearchParams: () => mockSearchParams,
|
|
}));
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQueryClient: () => ({ setQueryData: mockSetQueryData }),
|
|
}));
|
|
|
|
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
|
|
// exercised rather than silently diverging from the source of truth.
|
|
vi.mock("@multica/core/auth", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/auth")>(
|
|
"@multica/core/auth",
|
|
);
|
|
return {
|
|
...actual,
|
|
useAuthStore: (selector: (s: unknown) => unknown) =>
|
|
selector({ loginWithGoogle: mockLoginWithGoogle }),
|
|
};
|
|
});
|
|
|
|
vi.mock("@multica/core/workspace/queries", () => ({
|
|
workspaceKeys: {
|
|
list: () => ["workspaces"],
|
|
myInvitations: () => ["invitations", "mine"],
|
|
},
|
|
}));
|
|
|
|
vi.mock("@multica/core/api", () => ({
|
|
api: {
|
|
listWorkspaces: mockListWorkspaces,
|
|
listMyInvitations: mockListMyInvitations,
|
|
googleLogin: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import CallbackPage from "./page";
|
|
|
|
describe("CallbackPage", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Snapshot keys before deleting — forEach + delete skips entries because
|
|
// the iteration index advances while the underlying list shrinks.
|
|
Array.from(mockSearchParams.keys()).forEach((k) =>
|
|
mockSearchParams.delete(k),
|
|
);
|
|
mockSearchParams.set("code", "test-code");
|
|
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
|
mockListWorkspaces.mockResolvedValue([]);
|
|
mockListMyInvitations.mockResolvedValue([]);
|
|
});
|
|
|
|
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
|
|
mockSearchParams.set("state", "next:/invite/abc123");
|
|
render(<CallbackPage />);
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
|
});
|
|
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
|
// nextUrl is a fast path — listMyInvitations should not be queried.
|
|
expect(mockListMyInvitations).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("unonboarded user with no next= and no pending invitations lands on /onboarding", async () => {
|
|
render(<CallbackPage />);
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
|
});
|
|
expect(mockListMyInvitations).toHaveBeenCalled();
|
|
});
|
|
|
|
it("unonboarded user with pending invitations lands on /invitations", async () => {
|
|
mockListMyInvitations.mockResolvedValue([
|
|
{
|
|
id: "inv-1",
|
|
workspace_id: "ws-1",
|
|
workspace_name: "Acme",
|
|
role: "member",
|
|
status: "pending",
|
|
},
|
|
]);
|
|
render(<CallbackPage />);
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith(paths.invitations());
|
|
});
|
|
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
|
});
|
|
|
|
it("onboarded user with workspace lands in that workspace", async () => {
|
|
mockLoginWithGoogle.mockResolvedValue(
|
|
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
|
);
|
|
mockListWorkspaces.mockResolvedValue([
|
|
{
|
|
id: "ws-1",
|
|
name: "Acme",
|
|
slug: "acme",
|
|
description: null,
|
|
context: null,
|
|
settings: {},
|
|
repos: [],
|
|
issue_prefix: "ACME",
|
|
created_at: "",
|
|
updated_at: "",
|
|
},
|
|
]);
|
|
render(<CallbackPage />);
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith(paths.workspace("acme").issues());
|
|
});
|
|
// Already-onboarded users skip the listMyInvitations check; new invites
|
|
// surface in the sidebar instead of the wall.
|
|
expect(mockListMyInvitations).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
|
|
mockLoginWithGoogle.mockResolvedValue(
|
|
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
|
);
|
|
mockSearchParams.set("state", "next:https://evil.example");
|
|
|
|
render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalled();
|
|
});
|
|
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
|
|
});
|
|
|
|
it("onboarded user honors a safe next= target (e.g. /invite/{id})", async () => {
|
|
mockLoginWithGoogle.mockResolvedValue(
|
|
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
|
);
|
|
mockSearchParams.set("state", "next:/invite/abc123");
|
|
|
|
render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
|
});
|
|
});
|
|
|
|
it("falls through to /onboarding when listMyInvitations errors", async () => {
|
|
mockListMyInvitations.mockRejectedValue(new Error("network"));
|
|
render(<CallbackPage />);
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
|
});
|
|
});
|
|
});
|