multica/apps/web/lib/auth-context.test.tsx

437 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
// Mock next/navigation
const mockPush = vi.fn();
const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
}));
// Must use vi.hoisted so the mock object is defined before vi.mock factory runs
const mockApi = vi.hoisted(() => ({
setToken: vi.fn(),
setWorkspaceId: vi.fn(),
login: vi.fn(),
getMe: vi.fn(),
listWorkspaces: vi.fn(),
listMembers: vi.fn(),
listAgents: vi.fn(),
createWorkspace: vi.fn(),
updateMe: vi.fn(),
leaveWorkspace: vi.fn(),
deleteWorkspace: vi.fn(),
}));
vi.mock("./api", () => ({
api: mockApi,
}));
import { AuthProvider, useAuth } from "./auth-context";
const mockUser: User = {
id: "user-1",
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const mockWorkspace: Workspace = {
id: "ws-1",
name: "Test WS",
slug: "test",
description: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const mockMembers: MemberWithUser[] = [
{
id: "member-1",
workspace_id: "ws-1",
user_id: "user-1",
role: "owner",
created_at: "2026-01-01T00:00:00Z",
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
},
{
id: "member-2",
workspace_id: "ws-1",
user_id: "user-2",
role: "member",
created_at: "2026-01-01T00:00:00Z",
name: "Other User",
email: "other@multica.ai",
avatar_url: null,
},
];
const mockAgents: Agent[] = [
{
id: "agent-1",
workspace_id: "ws-1",
name: "Claude",
avatar_url: null,
status: "idle",
runtime_mode: "cloud",
runtime_config: {},
visibility: "workspace",
max_concurrent_tasks: 3,
owner_id: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
function wrapper({ children }: { children: React.ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
}
describe("AuthContext", () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear localStorage manually since jsdom may not have .clear()
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
});
it("starts with null user when no token stored", async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.user).toBeNull();
expect(result.current.workspace).toBeNull();
});
it("login stores token and navigates to /issues", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai", "Test User");
});
expect(mockApi.login).toHaveBeenCalledWith("test@multica.ai", "Test User");
expect(mockApi.setToken).toHaveBeenCalledWith("test-jwt");
expect(localStorage.getItem("multica_token")).toBe("test-jwt");
expect(result.current.user).toEqual(mockUser);
expect(result.current.workspace).toEqual(mockWorkspace);
expect(result.current.members).toEqual(mockMembers);
expect(result.current.agents).toEqual(mockAgents);
expect(mockPush).toHaveBeenCalledWith("/issues");
});
it("logout clears state and navigates to /login", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
act(() => {
result.current.logout();
});
expect(localStorage.getItem("multica_token")).toBeNull();
expect(localStorage.getItem("multica_workspace_id")).toBeNull();
expect(result.current.user).toBeNull();
expect(result.current.workspace).toBeNull();
expect(result.current.members).toEqual([]);
expect(result.current.agents).toEqual([]);
expect(mockPush).toHaveBeenCalledWith("/login");
});
it("getMemberName returns correct name for known user", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getMemberName("user-1")).toBe("Test User");
expect(result.current.getMemberName("user-2")).toBe("Other User");
expect(result.current.getMemberName("unknown")).toBe("Unknown");
});
it("getAgentName returns correct name for known agent", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getAgentName("agent-1")).toBe("Claude");
expect(result.current.getAgentName("unknown")).toBe("Unknown Agent");
});
it("getActorName dispatches to member or agent", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getActorName("member", "user-1")).toBe("Test User");
expect(result.current.getActorName("agent", "agent-1")).toBe("Claude");
expect(result.current.getActorName("system", "xxx")).toBe("System");
});
it("getActorInitials returns uppercase initials", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getActorInitials("member", "user-1")).toBe("TU");
expect(result.current.getActorInitials("agent", "agent-1")).toBe("C");
});
it("initializes from localStorage token on mount", async () => {
localStorage.setItem("multica_token", "stored-token");
localStorage.setItem("multica_workspace_id", "ws-1");
mockApi.getMe.mockResolvedValueOnce(mockUser);
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockApi.setToken).toHaveBeenCalledWith("stored-token");
expect(result.current.user).toEqual(mockUser);
expect(result.current.workspace).toEqual(mockWorkspace);
});
it("updateWorkspace updates workspace in context", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.workspace?.name).toBe("Test WS");
const updated: Workspace = { ...mockWorkspace, name: "Renamed WS", description: "new desc" };
act(() => {
result.current.updateWorkspace(updated);
});
expect(result.current.workspace?.name).toBe("Renamed WS");
expect(result.current.workspace?.description).toBe("new desc");
});
it("clears token when stored token is invalid", async () => {
localStorage.setItem("multica_token", "invalid-token");
mockApi.getMe.mockRejectedValueOnce(new Error("Unauthorized"));
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.user).toBeNull();
expect(localStorage.getItem("multica_token")).toBeNull();
});
it("initialization prefers stored workspace ID from list", async () => {
const mockWorkspace2: Workspace = {
id: "ws-2",
name: "Second WS",
slug: "second",
description: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
localStorage.setItem("multica_token", "stored-token");
localStorage.setItem("multica_workspace_id", "ws-2");
mockApi.getMe.mockResolvedValueOnce(mockUser);
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace, mockWorkspace2]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.workspace).toEqual(mockWorkspace2);
expect(result.current.workspaces).toHaveLength(2);
});
it("initialization falls back to first workspace when stored ID not in list", async () => {
localStorage.setItem("multica_token", "stored-token");
localStorage.setItem("multica_workspace_id", "ws-deleted");
mockApi.getMe.mockResolvedValueOnce(mockUser);
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.workspace).toEqual(mockWorkspace);
});
it("createWorkspace calls API and adds to workspaces list", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const newWs: Workspace = {
id: "ws-new",
name: "New WS",
slug: "new-ws",
description: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
mockApi.createWorkspace.mockResolvedValueOnce(newWs);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
let created: Workspace | undefined;
await act(async () => {
created = await result.current.createWorkspace({ name: "New WS", slug: "new-ws" });
});
expect(mockApi.createWorkspace).toHaveBeenCalledWith({ name: "New WS", slug: "new-ws" });
expect(created).toEqual(newWs);
expect(result.current.workspaces).toHaveLength(2);
expect(result.current.workspaces[1]).toEqual(newWs);
});
it("switchWorkspace updates context and calls setWorkspaceId", async () => {
const mockWorkspace2: Workspace = {
id: "ws-2",
name: "Second WS",
slug: "second",
description: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace, mockWorkspace2]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
// Setup mocks for the switch
mockApi.listMembers.mockResolvedValueOnce([]);
mockApi.listAgents.mockResolvedValueOnce([]);
await act(async () => {
await result.current.switchWorkspace("ws-2");
});
expect(mockApi.setWorkspaceId).toHaveBeenCalledWith("ws-2");
expect(localStorage.getItem("multica_workspace_id")).toBe("ws-2");
expect(mockRefresh).toHaveBeenCalled();
});
});