multica/apps/web/lib/auth-context.test.tsx
Jiayuan Zhang 1ba0fb071a fix(web): fix stale state bugs, add real-time updates, and build verification pipeline
- Fix kanban board columns not adapting to available width (w-64 → flex-1)
- Fix workspace name not updating in sidebar after save in settings
- Fix comments leaking across issues when navigating between issue details
- Fix duplicate issue appearing on create (race between callback and WebSocket)
- Add real-time WebSocket listeners for agents and inbox pages
- Add `make check` one-click verification pipeline (typecheck + tests + E2E)
- Add E2E test fixtures for self-contained test data setup/teardown
- Add settings E2E test and updateWorkspace unit test
- Make `make start/setup` reuse existing PostgreSQL if already running
- Update CLAUDE.md with AI agent verification loop and E2E test patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 12:44:49 +08:00

308 lines
9.7 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();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
// 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(),
}));
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();
});
});