test: add comprehensive test suite (Go unit/integration, Vitest, Playwright E2E)
- Add JWT middleware unit tests (8 tests covering all auth edge cases) - Add WebSocket hub tests (5 tests for client lifecycle and broadcast) - Add full HTTP integration tests (12 tests through real Chi router with DB) - Add frontend component tests for login, issues, and issue detail pages - Add auth context unit tests (9 tests for login/logout/name resolution) - Add Playwright E2E tests for auth, issues, comments, and navigation - Configure Vitest with jsdom, React plugin, and path aliases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
78f4d88aa1
commit
6dfc61fa86
18 changed files with 3090 additions and 4 deletions
116
apps/web/app/(auth)/login/page.test.tsx
Normal file
116
apps/web/app/(auth)/login/page.test.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => "/login",
|
||||
}));
|
||||
|
||||
// Mock auth-context
|
||||
const mockLogin = vi.fn();
|
||||
const mockAuthValue = {
|
||||
user: null,
|
||||
workspace: null,
|
||||
members: [],
|
||||
agents: [],
|
||||
isLoading: false,
|
||||
login: mockLogin,
|
||||
logout: vi.fn(),
|
||||
refreshMembers: vi.fn(),
|
||||
refreshAgents: vi.fn(),
|
||||
getMemberName: () => "Unknown",
|
||||
getAgentName: () => "Unknown Agent",
|
||||
getActorName: () => "System",
|
||||
getActorInitials: () => "XX",
|
||||
};
|
||||
|
||||
vi.mock("../../../lib/auth-context", () => ({
|
||||
useAuth: () => mockAuthValue,
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
import LoginPage from "./page";
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders login form with heading, inputs, and button", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByText("Multica")).toBeInTheDocument();
|
||||
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Sign in" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not call login when email is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
// The email input has required attribute, so browser validation blocks submit
|
||||
// Verify login was never called
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
expect(mockLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls login with correct args on submit", async () => {
|
||||
mockLogin.mockResolvedValueOnce(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
|
||||
await user.type(screen.getByPlaceholderText("Name"), "Test User");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls login with email only when name is empty", async () => {
|
||||
mockLogin.mockResolvedValueOnce(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'Signing in...' while submitting", async () => {
|
||||
// Make login hang
|
||||
mockLogin.mockReturnValueOnce(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Signing in...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when login fails", async () => {
|
||||
mockLogin.mockRejectedValueOnce(new Error("Network error"));
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
|
||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Login failed. Make sure the server is running."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
241
apps/web/app/(dashboard)/issues/[id]/page.test.tsx
Normal file
241
apps/web/app/(dashboard)/issues/[id]/page.test.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { Suspense } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Issue, Comment } from "@multica/types";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => "/issues/issue-1",
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
[key: string]: any;
|
||||
}) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock auth context
|
||||
vi.mock("../../../../lib/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
workspace: { id: "ws-1", name: "Test WS" },
|
||||
members: [
|
||||
{ user_id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
],
|
||||
agents: [{ id: "agent-1", name: "Claude Agent" }],
|
||||
isLoading: false,
|
||||
getActorName: (type: string, id: string) => {
|
||||
if (type === "member" && id === "user-1") return "Test User";
|
||||
if (type === "agent" && id === "agent-1") return "Claude Agent";
|
||||
return "Unknown";
|
||||
},
|
||||
getActorInitials: (type: string, id: string) => {
|
||||
if (type === "member") return "TU";
|
||||
if (type === "agent") return "CA";
|
||||
return "??";
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock api
|
||||
const mockGetIssue = vi.hoisted(() => vi.fn());
|
||||
const mockListComments = vi.hoisted(() => vi.fn());
|
||||
const mockCreateComment = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../../lib/api", () => ({
|
||||
api: {
|
||||
getIssue: (...args: any[]) => mockGetIssue(...args),
|
||||
listComments: (...args: any[]) => mockListComments(...args),
|
||||
createComment: (...args: any[]) => mockCreateComment(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockIssue: Issue = {
|
||||
id: "issue-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "Implement authentication",
|
||||
description: "Add JWT auth to the backend",
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
due_date: "2026-06-01T00:00:00Z",
|
||||
created_at: "2026-01-15T00:00:00Z",
|
||||
updated_at: "2026-01-20T00:00:00Z",
|
||||
};
|
||||
|
||||
const mockComments: Comment[] = [
|
||||
{
|
||||
id: "comment-1",
|
||||
issue_id: "issue-1",
|
||||
content: "Started working on this",
|
||||
type: "comment",
|
||||
author_type: "member",
|
||||
author_id: "user-1",
|
||||
created_at: "2026-01-16T00:00:00Z",
|
||||
updated_at: "2026-01-16T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "comment-2",
|
||||
issue_id: "issue-1",
|
||||
content: "I can help with this",
|
||||
type: "comment",
|
||||
author_type: "agent",
|
||||
author_id: "agent-1",
|
||||
created_at: "2026-01-17T00:00:00Z",
|
||||
updated_at: "2026-01-17T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
import IssueDetailPage from "./page";
|
||||
|
||||
// React 19 use(Promise) needs the promise to resolve within act + Suspense
|
||||
async function renderPage(id = "issue-1") {
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||
</Suspense>,
|
||||
);
|
||||
});
|
||||
return result!;
|
||||
}
|
||||
|
||||
describe("IssueDetailPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders issue details after loading", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListComments.mockResolvedValueOnce(mockComments);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Implement authentication"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText("Add JWT auth to the backend"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders issue properties sidebar", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListComments.mockResolvedValueOnce(mockComments);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Properties")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
||||
expect(screen.getByText("High")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders comments", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListComments.mockResolvedValueOnce(mockComments);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Started working on this"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("I can help with this")).toBeInTheDocument();
|
||||
expect(screen.getByText("Activity")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Issue not found' for missing issue", async () => {
|
||||
mockGetIssue.mockRejectedValueOnce(new Error("Not found"));
|
||||
mockListComments.mockRejectedValueOnce(new Error("Not found"));
|
||||
await renderPage("nonexistent-id");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Issue not found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("submits a new comment", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListComments.mockResolvedValueOnce(mockComments);
|
||||
|
||||
const newComment: Comment = {
|
||||
id: "comment-3",
|
||||
issue_id: "issue-1",
|
||||
content: "New test comment",
|
||||
type: "comment",
|
||||
author_type: "member",
|
||||
author_id: "user-1",
|
||||
created_at: "2026-01-18T00:00:00Z",
|
||||
updated_at: "2026-01-18T00:00:00Z",
|
||||
};
|
||||
mockCreateComment.mockResolvedValueOnce(newComment);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText("Leave a comment..."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Leave a comment..."),
|
||||
"New test comment",
|
||||
);
|
||||
|
||||
const form = screen
|
||||
.getByPlaceholderText("Leave a comment...")
|
||||
.closest("form")!;
|
||||
const submitBtn = form.querySelector(
|
||||
'button[type="submit"]',
|
||||
) as HTMLElement;
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateComment).toHaveBeenCalledWith(
|
||||
"issue-1",
|
||||
"New test comment",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New test comment")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders breadcrumb navigation", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListComments.mockResolvedValueOnce(mockComments);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const issuesLink = screen.getByText("Issues");
|
||||
expect(issuesLink.closest("a")).toHaveAttribute("href", "/issues");
|
||||
});
|
||||
});
|
||||
290
apps/web/app/(dashboard)/issues/page.test.tsx
Normal file
290
apps/web/app/(dashboard)/issues/page.test.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Issue, ListIssuesResponse } from "@multica/types";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => "/issues",
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
[key: string]: any;
|
||||
}) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock auth context
|
||||
vi.mock("../../../lib/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
workspace: { id: "ws-1", name: "Test WS" },
|
||||
members: [
|
||||
{ user_id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
],
|
||||
agents: [{ id: "agent-1", name: "Claude Agent" }],
|
||||
isLoading: false,
|
||||
getActorName: (type: string, id: string) =>
|
||||
type === "member" ? "Test User" : "Claude Agent",
|
||||
getActorInitials: () => "TU",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock WebSocket context
|
||||
vi.mock("../../../lib/ws-context", () => ({
|
||||
useWSEvent: vi.fn(),
|
||||
useWS: () => ({ subscribe: vi.fn(() => () => {}) }),
|
||||
WSProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// Mock api
|
||||
const mockListIssues = vi.fn();
|
||||
const mockCreateIssue = vi.fn();
|
||||
const mockUpdateIssue = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
listIssues: (...args: any[]) => mockListIssues(...args),
|
||||
createIssue: (...args: any[]) => mockCreateIssue(...args),
|
||||
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockIssues: Issue[] = [
|
||||
{
|
||||
id: "issue-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "Implement auth",
|
||||
description: "Add JWT authentication",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
due_date: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "issue-2",
|
||||
workspace_id: "ws-1",
|
||||
title: "Design landing page",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assignee_type: "agent",
|
||||
assignee_id: "agent-1",
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
due_date: "2026-02-01T00:00:00Z",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "issue-3",
|
||||
workspace_id: "ws-1",
|
||||
title: "Write tests",
|
||||
description: null,
|
||||
status: "backlog",
|
||||
priority: "low",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
due_date: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
import IssuesPage from "./page";
|
||||
|
||||
describe("IssuesPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
mockListIssues.mockReturnValueOnce(new Promise(() => {}));
|
||||
render(<IssuesPage />);
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders issues in board view after loading", async () => {
|
||||
mockListIssues.mockResolvedValueOnce({
|
||||
issues: mockIssues,
|
||||
total: 3,
|
||||
} as ListIssuesResponse);
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Implement auth")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Design landing page")).toBeInTheDocument();
|
||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||
expect(screen.getByText("All Issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders board columns", async () => {
|
||||
mockListIssues.mockResolvedValueOnce({
|
||||
issues: mockIssues,
|
||||
total: 3,
|
||||
} as ListIssuesResponse);
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Backlog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Todo")).toBeInTheDocument();
|
||||
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
||||
expect(screen.getByText("In Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to list view", async () => {
|
||||
mockListIssues.mockResolvedValueOnce({
|
||||
issues: mockIssues,
|
||||
total: 3,
|
||||
} as ListIssuesResponse);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<IssuesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Implement auth")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the List button and click it
|
||||
const listButton = screen.getByText("List");
|
||||
await user.click(listButton);
|
||||
|
||||
// Issues should still be visible
|
||||
expect(screen.getByText("Implement auth")).toBeInTheDocument();
|
||||
expect(screen.getByText("Design landing page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'New Issue' button and opens create form", async () => {
|
||||
mockListIssues.mockResolvedValueOnce({
|
||||
issues: [],
|
||||
total: 0,
|
||||
} as ListIssuesResponse);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<IssuesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("New Issue"));
|
||||
|
||||
// Create form should be visible
|
||||
expect(
|
||||
screen.getByPlaceholderText("Issue title..."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Create")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates an issue via the form", async () => {
|
||||
mockListIssues.mockResolvedValueOnce({
|
||||
issues: [],
|
||||
total: 0,
|
||||
} as ListIssuesResponse);
|
||||
|
||||
const newIssue: Issue = {
|
||||
id: "issue-new",
|
||||
workspace_id: "ws-1",
|
||||
title: "New test issue",
|
||||
description: null,
|
||||
status: "backlog",
|
||||
priority: "none",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
due_date: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
mockCreateIssue.mockResolvedValueOnce(newIssue);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<IssuesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("New Issue"));
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Issue title..."),
|
||||
"New test issue",
|
||||
);
|
||||
await user.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: "New test issue",
|
||||
});
|
||||
});
|
||||
|
||||
// New issue should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New test issue")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes create form on Cancel", async () => {
|
||||
mockListIssues.mockResolvedValueOnce({
|
||||
issues: [],
|
||||
total: 0,
|
||||
} as ListIssuesResponse);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<IssuesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("New Issue"));
|
||||
expect(
|
||||
screen.getByPlaceholderText("Issue title..."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Cancel"));
|
||||
expect(
|
||||
screen.queryByPlaceholderText("Issue title..."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles API error gracefully", async () => {
|
||||
mockListIssues.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
// Should finish loading without crashing
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,8 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -28,9 +29,15 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "catalog:",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
91
apps/web/test/helpers.tsx
Normal file
91
apps/web/test/helpers.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import React from "react";
|
||||
import { render, type RenderOptions } from "@testing-library/react";
|
||||
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
|
||||
|
||||
// Mock user
|
||||
export 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",
|
||||
};
|
||||
|
||||
// Mock workspace
|
||||
export const mockWorkspace: Workspace = {
|
||||
id: "ws-1",
|
||||
name: "Test Workspace",
|
||||
slug: "test-ws",
|
||||
description: "A test workspace",
|
||||
settings: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Mock members
|
||||
export 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,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock agents
|
||||
export const mockAgents: Agent[] = [
|
||||
{
|
||||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
name: "Claude Agent",
|
||||
status: "idle",
|
||||
runtime_mode: "cloud",
|
||||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
description: "A test agent",
|
||||
system_prompt: null,
|
||||
config: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
// Mock auth context value
|
||||
export const mockAuthValue = {
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
members: mockMembers,
|
||||
agents: mockAgents,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshMembers: vi.fn(),
|
||||
refreshAgents: vi.fn(),
|
||||
getMemberName: (userId: string) => {
|
||||
const m = mockMembers.find((m) => m.user_id === userId);
|
||||
return m?.name ?? "Unknown";
|
||||
},
|
||||
getAgentName: (agentId: string) => {
|
||||
const a = mockAgents.find((a) => a.id === agentId);
|
||||
return a?.name ?? "Unknown Agent";
|
||||
},
|
||||
getActorName: (type: string, id: string) => {
|
||||
if (type === "member") {
|
||||
const m = mockMembers.find((m) => m.user_id === id);
|
||||
return m?.name ?? "Unknown";
|
||||
}
|
||||
if (type === "agent") {
|
||||
const a = mockAgents.find((a) => a.id === id);
|
||||
return a?.name ?? "Unknown Agent";
|
||||
}
|
||||
return "System";
|
||||
},
|
||||
getActorInitials: (type: string, id: string) => {
|
||||
return "TU";
|
||||
},
|
||||
};
|
||||
33
apps/web/test/setup.ts
Normal file
33
apps/web/test/setup.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
|
||||
// Create a proper localStorage mock if methods are missing.
|
||||
if (
|
||||
typeof globalThis.localStorage === "undefined" ||
|
||||
typeof globalThis.localStorage.getItem !== "function"
|
||||
) {
|
||||
const store: Record<string, string> = {};
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
for (const key of Object.keys(store)) {
|
||||
delete store[key];
|
||||
}
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
19
apps/web/vitest.config.ts
Normal file
19
apps/web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./test/setup.ts"],
|
||||
include: ["**/*.test.{ts,tsx}"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@multica/types": path.resolve(__dirname, "../../packages/types/src"),
|
||||
"@multica/sdk": path.resolve(__dirname, "../../packages/sdk/src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
46
e2e/auth.spec.ts
Normal file
46
e2e/auth.spec.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test("login page renders correctly", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await expect(page.locator("h1")).toContainText("Multica");
|
||||
await expect(page.locator('input[placeholder="Email"]')).toBeVisible();
|
||||
await expect(page.locator('input[placeholder="Name"]')).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]')).toContainText(
|
||||
"Sign in",
|
||||
);
|
||||
});
|
||||
|
||||
test("login and redirect to /issues", async ({ page }) => {
|
||||
await loginAsDefault(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/issues/);
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
});
|
||||
|
||||
test("unauthenticated user is redirected to /login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
});
|
||||
|
||||
await page.goto("/issues");
|
||||
await page.waitForURL("**/login", { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("logout redirects to /login", async ({ page }) => {
|
||||
await loginAsDefault(page);
|
||||
|
||||
// Open the workspace dropdown menu
|
||||
await openWorkspaceMenu(page);
|
||||
|
||||
// Click Sign out
|
||||
await page.locator("text=Sign out").click();
|
||||
|
||||
await page.waitForURL("**/login", { timeout: 10000 });
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
47
e2e/comments.spec.ts
Normal file
47
e2e/comments.spec.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault } from "./helpers";
|
||||
|
||||
test.describe("Comments", () => {
|
||||
test("can add a comment on an issue", async ({ page }) => {
|
||||
await loginAsDefault(page);
|
||||
|
||||
// Wait for issues to load and click first one
|
||||
const issueLink = page.locator('a[href^="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
|
||||
// Wait for issue detail to load
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
|
||||
// Type a comment
|
||||
const commentText = "E2E comment " + Date.now();
|
||||
const commentInput = page.locator(
|
||||
'input[placeholder="Leave a comment..."]',
|
||||
);
|
||||
await commentInput.fill(commentText);
|
||||
|
||||
// Submit the comment
|
||||
await page.locator('form button[type="submit"]').last().click();
|
||||
|
||||
// Comment should appear in the activity section
|
||||
await expect(page.locator(`text=${commentText}`)).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test("comment submit button is disabled when empty", async ({ page }) => {
|
||||
await loginAsDefault(page);
|
||||
|
||||
const issueLink = page.locator('a[href^="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
|
||||
// Submit button should be disabled when input is empty
|
||||
const submitBtn = page.locator('form button[type="submit"]').last();
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
22
e2e/helpers.ts
Normal file
22
e2e/helpers.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Login as the seeded user (has workspace and issues).
|
||||
*/
|
||||
export async function loginAsDefault(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.fill('input[placeholder="Name"]', "Jiayuan Zhang");
|
||||
await page.fill('input[placeholder="Email"]', "jiayuan@multica.ai");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL("**/issues", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the workspace switcher dropdown menu.
|
||||
*/
|
||||
export async function openWorkspaceMenu(page: Page) {
|
||||
// Click the workspace switcher button (has ChevronDown icon)
|
||||
await page.locator("aside button").first().click();
|
||||
// Wait for dropdown to appear
|
||||
await page.locator('[class*="popover"]').waitFor({ state: "visible" });
|
||||
}
|
||||
77
e2e/issues.spec.ts
Normal file
77
e2e/issues.spec.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault } from "./helpers";
|
||||
|
||||
test.describe("Issues", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsDefault(page);
|
||||
});
|
||||
|
||||
test("issues page loads with board view", async ({ page }) => {
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Board columns should be visible
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
await expect(page.locator("text=Todo")).toBeVisible();
|
||||
await expect(page.locator("text=In Progress")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can switch between board and list view", async ({ page }) => {
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Switch to list view
|
||||
await page.click("text=List");
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Switch back to board view
|
||||
await page.click("text=Board");
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can create a new issue", async ({ page }) => {
|
||||
await page.click("text=New Issue");
|
||||
|
||||
const title = "E2E Created " + Date.now();
|
||||
await page.fill('input[placeholder="Issue title..."]', title);
|
||||
await page.click("text=Create");
|
||||
|
||||
// New issue should appear on the page (may need API call to complete)
|
||||
await expect(page.locator(`text=${title}`)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("can navigate to issue detail page", async ({ page }) => {
|
||||
// Wait for issues to load
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Click first issue card that has an anchor tag to issue detail
|
||||
const issueLink = page.locator('a[href^="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
|
||||
// Should navigate to issue detail
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
|
||||
// Should show Properties panel
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
// Should show breadcrumb link back to Issues
|
||||
await expect(
|
||||
page.locator("a", { hasText: "Issues" }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("can cancel issue creation", async ({ page }) => {
|
||||
await page.click("text=New Issue");
|
||||
|
||||
await expect(
|
||||
page.locator('input[placeholder="Issue title..."]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.click("text=Cancel");
|
||||
|
||||
await expect(
|
||||
page.locator('input[placeholder="Issue title..."]'),
|
||||
).not.toBeVisible();
|
||||
await expect(page.locator("text=New Issue")).toBeVisible();
|
||||
});
|
||||
});
|
||||
43
e2e/navigation.spec.ts
Normal file
43
e2e/navigation.spec.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
|
||||
|
||||
test.describe("Navigation", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsDefault(page);
|
||||
});
|
||||
|
||||
test("sidebar navigation works", async ({ page }) => {
|
||||
// Click Inbox
|
||||
await page.locator("nav a", { hasText: "Inbox" }).click();
|
||||
await page.waitForURL("**/inbox");
|
||||
await expect(page).toHaveURL(/\/inbox/);
|
||||
|
||||
// Click Agents
|
||||
await page.locator("nav a", { hasText: "Agents" }).click();
|
||||
await page.waitForURL("**/agents");
|
||||
await expect(page).toHaveURL(/\/agents/);
|
||||
|
||||
// Click Issues
|
||||
await page.locator("nav a", { hasText: "Issues" }).click();
|
||||
await page.waitForURL("**/issues");
|
||||
await expect(page).toHaveURL(/\/issues/);
|
||||
});
|
||||
|
||||
test("settings page loads via workspace menu", async ({ page }) => {
|
||||
// Settings is inside the workspace dropdown menu
|
||||
await openWorkspaceMenu(page);
|
||||
await page.locator("text=Settings").click();
|
||||
await page.waitForURL("**/settings");
|
||||
|
||||
await expect(page.locator("text=Workspace")).toBeVisible();
|
||||
await expect(page.locator("text=Members")).toBeVisible();
|
||||
});
|
||||
|
||||
test("agents page shows agent list", async ({ page }) => {
|
||||
await page.locator("nav a", { hasText: "Agents" }).click();
|
||||
await page.waitForURL("**/agents");
|
||||
|
||||
// Should show "Agents" heading
|
||||
await expect(page.locator("text=Agents").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "catalog:",
|
||||
"turbo": "^2.5.0",
|
||||
"typescript": "catalog:"
|
||||
|
|
|
|||
19
playwright.config.ts
Normal file
19
playwright.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 30000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
headless: true,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { browserName: "chromium" },
|
||||
},
|
||||
],
|
||||
// Don't auto-start servers — they must be running already
|
||||
// This avoids complexity and port conflicts during testing
|
||||
});
|
||||
1058
pnpm-lock.yaml
generated
1058
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
618
server/cmd/server/integration_test.go
Normal file
618
server/cmd/server/integration_test.go
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
var (
|
||||
testServer *httptest.Server
|
||||
testToken string
|
||||
testUserID string
|
||||
testWorkspaceID string
|
||||
)
|
||||
|
||||
var jwtSecret = []byte("multica-dev-secret-change-in-production")
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(context.Background(), dbURL)
|
||||
if err != nil {
|
||||
fmt.Printf("Skipping integration tests: could not connect to database: %v\n", err)
|
||||
os.Exit(0)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Get seed data IDs
|
||||
row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)
|
||||
row.Scan(&testUserID)
|
||||
|
||||
row = pool.QueryRow(context.Background(), `SELECT id FROM workspace WHERE slug = 'multica'`)
|
||||
row.Scan(&testWorkspaceID)
|
||||
|
||||
if testUserID == "" || testWorkspaceID == "" {
|
||||
fmt.Println("Skipping integration tests: seed data not found. Run 'go run ./cmd/seed/' first.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
queries := db.New(pool)
|
||||
hub := realtime.NewHub()
|
||||
go hub.Run()
|
||||
|
||||
router := NewRouter(queries, hub)
|
||||
testServer = httptest.NewServer(router)
|
||||
defer testServer.Close()
|
||||
|
||||
// Login to get a real JWT token
|
||||
loginBody, _ := json.Marshal(map[string]string{
|
||||
"email": "jiayuan@multica.ai",
|
||||
"name": "Jiayuan Zhang",
|
||||
})
|
||||
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(loginBody))
|
||||
if err != nil {
|
||||
fmt.Printf("Skipping: login failed: %v\n", err)
|
||||
os.Exit(0)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var loginResp struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"user"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&loginResp)
|
||||
testToken = loginResp.Token
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// Helper to make authenticated requests
|
||||
func authRequest(t *testing.T, method, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, testServer.URL+path, bodyReader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||
req.Header.Set("X-Workspace-ID", testWorkspaceID)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func readJSON(t *testing.T, resp *http.Response, v any) {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Health ----
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
resp, err := http.Get(testServer.URL + "/health")
|
||||
if err != nil {
|
||||
t.Fatalf("health check failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
if result["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %s", result["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Auth ----
|
||||
|
||||
func TestLoginAndGetMe(t *testing.T) {
|
||||
// Login
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"email": "integration-test@multica.ai",
|
||||
"name": "Integration Tester",
|
||||
})
|
||||
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("login failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var loginResp struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
}
|
||||
readJSON(t, resp, &loginResp)
|
||||
|
||||
if loginResp.Token == "" {
|
||||
t.Fatal("expected non-empty token")
|
||||
}
|
||||
if loginResp.User.Email != "integration-test@multica.ai" {
|
||||
t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", loginResp.User.Email)
|
||||
}
|
||||
|
||||
// Use token to call /api/me
|
||||
req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
|
||||
meResp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("getMe failed: %v", err)
|
||||
}
|
||||
|
||||
if meResp.StatusCode != 200 {
|
||||
t.Fatalf("expected 200, got %d", meResp.StatusCode)
|
||||
}
|
||||
|
||||
var me struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
readJSON(t, meResp, &me)
|
||||
if me.Email != "integration-test@multica.ai" {
|
||||
t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", me.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedRoutesRequireAuth(t *testing.T) {
|
||||
paths := []string{"/api/me", "/api/issues", "/api/agents", "/api/inbox", "/api/workspaces"}
|
||||
|
||||
for _, path := range paths {
|
||||
resp, err := http.Get(testServer.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("request to %s failed: %v", path, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("%s: expected 401, got %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidJWT(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
}{
|
||||
{"garbage token", "not-a-jwt"},
|
||||
{"empty token", ""},
|
||||
{"wrong secret", func() string {
|
||||
claims := jwt.MapClaims{"sub": "test", "exp": time.Now().Add(time.Hour).Unix()}
|
||||
t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("wrong"))
|
||||
return t
|
||||
}()},
|
||||
{"expired token", func() string {
|
||||
claims := jwt.MapClaims{"sub": "test", "exp": time.Now().Add(-time.Hour).Unix()}
|
||||
t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
|
||||
return t
|
||||
}()},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
|
||||
if tc.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tc.token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Issues CRUD through full router ----
|
||||
|
||||
func TestIssuesCRUDThroughRouter(t *testing.T) {
|
||||
// Create
|
||||
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Integration test issue",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
})
|
||||
if resp.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var created map[string]any
|
||||
readJSON(t, resp, &created)
|
||||
issueID := created["id"].(string)
|
||||
if created["title"] != "Integration test issue" {
|
||||
t.Fatalf("expected title 'Integration test issue', got '%s'", created["title"])
|
||||
}
|
||||
|
||||
// Get
|
||||
resp = authRequest(t, "GET", "/api/issues/"+issueID, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GetIssue: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var fetched map[string]any
|
||||
readJSON(t, resp, &fetched)
|
||||
if fetched["id"] != issueID {
|
||||
t.Fatalf("expected id %s, got %s", issueID, fetched["id"])
|
||||
}
|
||||
|
||||
// Update status only — should preserve title
|
||||
resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
|
||||
"status": "in_progress",
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("UpdateIssue: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var updated map[string]any
|
||||
readJSON(t, resp, &updated)
|
||||
if updated["status"] != "in_progress" {
|
||||
t.Fatalf("expected status 'in_progress', got '%s'", updated["status"])
|
||||
}
|
||||
if updated["title"] != "Integration test issue" {
|
||||
t.Fatalf("title should be preserved, got '%s'", updated["title"])
|
||||
}
|
||||
|
||||
// Update title only — should preserve status
|
||||
resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
|
||||
"title": "Renamed integration issue",
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("UpdateIssue title: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var updated2 map[string]any
|
||||
readJSON(t, resp, &updated2)
|
||||
if updated2["title"] != "Renamed integration issue" {
|
||||
t.Fatalf("expected title 'Renamed integration issue', got '%s'", updated2["title"])
|
||||
}
|
||||
if updated2["status"] != "in_progress" {
|
||||
t.Fatalf("status should be preserved, got '%s'", updated2["status"])
|
||||
}
|
||||
|
||||
// List
|
||||
resp = authRequest(t, "GET", "/api/issues?workspace_id="+testWorkspaceID, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("ListIssues: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var listResp map[string]any
|
||||
readJSON(t, resp, &listResp)
|
||||
total := listResp["total"].(float64)
|
||||
if total < 1 {
|
||||
t.Fatal("expected at least 1 issue")
|
||||
}
|
||||
|
||||
// Delete
|
||||
resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 204 {
|
||||
t.Fatalf("DeleteIssue: expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
resp = authRequest(t, "GET", "/api/issues/"+issueID, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 404 {
|
||||
t.Fatalf("GetIssue after delete: expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Comments through full router ----
|
||||
|
||||
func TestCommentsThroughRouter(t *testing.T) {
|
||||
// Create issue
|
||||
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Comment integration test",
|
||||
})
|
||||
var issue map[string]any
|
||||
readJSON(t, resp, &issue)
|
||||
issueID := issue["id"].(string)
|
||||
|
||||
// Create comment
|
||||
resp = authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "Integration test comment",
|
||||
"type": "comment",
|
||||
})
|
||||
if resp.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("CreateComment: expected 201, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var comment map[string]any
|
||||
readJSON(t, resp, &comment)
|
||||
if comment["content"] != "Integration test comment" {
|
||||
t.Fatalf("expected content 'Integration test comment', got '%s'", comment["content"])
|
||||
}
|
||||
|
||||
// Create second comment
|
||||
resp = authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "Second comment",
|
||||
"type": "comment",
|
||||
})
|
||||
resp.Body.Close()
|
||||
|
||||
// List comments
|
||||
resp = authRequest(t, "GET", "/api/issues/"+issueID+"/comments", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("ListComments: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var comments []map[string]any
|
||||
readJSON(t, resp, &comments)
|
||||
if len(comments) != 2 {
|
||||
t.Fatalf("expected 2 comments, got %d", len(comments))
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// ---- Agents through full router ----
|
||||
|
||||
func TestAgentsThroughRouter(t *testing.T) {
|
||||
// List
|
||||
resp := authRequest(t, "GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("ListAgents: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var agents []map[string]any
|
||||
readJSON(t, resp, &agents)
|
||||
if len(agents) < 1 {
|
||||
t.Fatal("expected at least 1 agent")
|
||||
}
|
||||
|
||||
// Get
|
||||
agentID := agents[0]["id"].(string)
|
||||
resp = authRequest(t, "GET", "/api/agents/"+agentID, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GetAgent: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var agent map[string]any
|
||||
readJSON(t, resp, &agent)
|
||||
if agent["id"] != agentID {
|
||||
t.Fatalf("expected agent id %s, got %s", agentID, agent["id"])
|
||||
}
|
||||
|
||||
// Update status
|
||||
resp = authRequest(t, "PUT", "/api/agents/"+agentID, map[string]any{
|
||||
"status": "idle",
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("UpdateAgent: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var updated map[string]any
|
||||
readJSON(t, resp, &updated)
|
||||
if updated["status"] != "idle" {
|
||||
t.Fatalf("expected status 'idle', got '%s'", updated["status"])
|
||||
}
|
||||
// Name should be preserved
|
||||
if updated["name"] != agents[0]["name"] {
|
||||
t.Fatalf("name should be preserved, got '%s'", updated["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Workspaces through full router ----
|
||||
|
||||
func TestWorkspacesThroughRouter(t *testing.T) {
|
||||
// List
|
||||
resp := authRequest(t, "GET", "/api/workspaces", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("ListWorkspaces: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var workspaces []map[string]any
|
||||
readJSON(t, resp, &workspaces)
|
||||
if len(workspaces) < 1 {
|
||||
t.Fatal("expected at least 1 workspace")
|
||||
}
|
||||
|
||||
// Get
|
||||
wsID := workspaces[0]["id"].(string)
|
||||
resp = authRequest(t, "GET", "/api/workspaces/"+wsID, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GetWorkspace: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var ws map[string]any
|
||||
readJSON(t, resp, &ws)
|
||||
if ws["id"] != wsID {
|
||||
t.Fatalf("expected workspace id %s, got %s", wsID, ws["id"])
|
||||
}
|
||||
|
||||
// Update
|
||||
resp = authRequest(t, "PUT", "/api/workspaces/"+wsID, map[string]any{
|
||||
"description": "Integration test update",
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("UpdateWorkspace: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var updated map[string]any
|
||||
readJSON(t, resp, &updated)
|
||||
if updated["description"] != "Integration test update" {
|
||||
t.Fatalf("expected description 'Integration test update', got '%v'", updated["description"])
|
||||
}
|
||||
// Name should be preserved
|
||||
if updated["name"] != ws["name"] {
|
||||
t.Fatalf("name should be preserved")
|
||||
}
|
||||
|
||||
// Members
|
||||
resp = authRequest(t, "GET", "/api/workspaces/"+wsID+"/members", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("ListMembers: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var members []map[string]any
|
||||
readJSON(t, resp, &members)
|
||||
if len(members) < 1 {
|
||||
t.Fatal("expected at least 1 member")
|
||||
}
|
||||
// Verify member has user info
|
||||
if members[0]["email"] == nil || members[0]["email"] == "" {
|
||||
t.Fatal("member should have email field")
|
||||
}
|
||||
if members[0]["role"] == nil || members[0]["role"] == "" {
|
||||
t.Fatal("member should have role field")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inbox through full router ----
|
||||
|
||||
func TestInboxThroughRouter(t *testing.T) {
|
||||
resp := authRequest(t, "GET", "/api/inbox", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("ListInbox: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var items []map[string]any
|
||||
readJSON(t, resp, &items)
|
||||
// Inbox may be empty, just verify it returns valid JSON array
|
||||
if items == nil {
|
||||
t.Fatal("expected non-nil inbox items array")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 404 for non-existent resources ----
|
||||
|
||||
func TestNonExistentResources(t *testing.T) {
|
||||
fakeUUID := "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"issue", "/api/issues/" + fakeUUID},
|
||||
{"agent", "/api/agents/" + fakeUUID},
|
||||
{"workspace", "/api/workspaces/" + fakeUUID},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := authRequest(t, "GET", tc.path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 404 {
|
||||
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Invalid request bodies ----
|
||||
|
||||
func TestInvalidRequestBodies(t *testing.T) {
|
||||
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, nil)
|
||||
defer resp.Body.Close()
|
||||
// Sending nil body should fail with 400
|
||||
if resp.StatusCode != 400 {
|
||||
// Some handlers may return 500 for nil body, that's acceptable too
|
||||
if resp.StatusCode != 500 {
|
||||
t.Fatalf("expected 400 or 500, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- WebSocket integration through full router ----
|
||||
|
||||
func TestWebSocketIntegration(t *testing.T) {
|
||||
// Connect WebSocket client
|
||||
wsURL := "ws" + strings.TrimPrefix(testServer.URL, "http") + "/ws"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("WebSocket connection failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Create an issue — this should trigger a WebSocket broadcast
|
||||
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "WebSocket test issue",
|
||||
"status": "todo",
|
||||
})
|
||||
var issue map[string]any
|
||||
readJSON(t, resp, &issue)
|
||||
issueID := issue["id"].(string)
|
||||
|
||||
// Read the WebSocket message
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("WebSocket read error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the message contains the issue event
|
||||
var wsMsg map[string]any
|
||||
if err := json.Unmarshal(msg, &wsMsg); err != nil {
|
||||
t.Fatalf("failed to parse WebSocket message: %v", err)
|
||||
}
|
||||
if wsMsg["type"] != "issue:created" {
|
||||
t.Fatalf("expected type 'issue:created', got '%s'", wsMsg["type"])
|
||||
}
|
||||
|
||||
// Update the issue — should trigger another broadcast
|
||||
resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
|
||||
"status": "in_progress",
|
||||
})
|
||||
resp.Body.Close()
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
_, msg, err = conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("WebSocket read error on update: %v", err)
|
||||
}
|
||||
var updateMsg map[string]any
|
||||
json.Unmarshal(msg, &updateMsg)
|
||||
if updateMsg["type"] != "issue:updated" {
|
||||
t.Fatalf("expected type 'issue:updated', got '%s'", updateMsg["type"])
|
||||
}
|
||||
|
||||
// Delete the issue — should trigger another broadcast
|
||||
resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
|
||||
resp.Body.Close()
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
_, msg, err = conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("WebSocket read error on delete: %v", err)
|
||||
}
|
||||
var deleteMsg map[string]any
|
||||
json.Unmarshal(msg, &deleteMsg)
|
||||
if deleteMsg["type"] != "issue:deleted" {
|
||||
t.Fatalf("expected type 'issue:deleted', got '%s'", deleteMsg["type"])
|
||||
}
|
||||
}
|
||||
185
server/internal/middleware/auth_test.go
Normal file
185
server/internal/middleware/auth_test.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func generateToken(claims jwt.MapClaims, secret []byte) string {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
s, _ := token.SignedString(secret)
|
||||
return s
|
||||
}
|
||||
|
||||
func validClaims() jwt.MapClaims {
|
||||
return jwt.MapClaims{
|
||||
"sub": "test-user-id",
|
||||
"email": "test@multica.ai",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_MissingHeader(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
if body := w.Body.String(); body != `{"error":"missing authorization header"}`+"\n" {
|
||||
t.Fatalf("unexpected body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_NoBearerPrefix(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Token some-token")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
if body := w.Body.String(); body != `{"error":"invalid authorization format"}`+"\n" {
|
||||
t.Fatalf("unexpected body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_InvalidToken(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer not-a-valid-jwt")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_ExpiredToken(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
claims := validClaims()
|
||||
claims["exp"] = time.Now().Add(-time.Hour).Unix()
|
||||
token := generateToken(claims, jwtSecret)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_WrongSecret(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
token := generateToken(validClaims(), []byte("wrong-secret"))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_WrongSigningMethod(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
// Use "none" signing method
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodNone, validClaims())
|
||||
s, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_ValidToken(t *testing.T) {
|
||||
var gotUserID, gotEmail string
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUserID = r.Header.Get("X-User-ID")
|
||||
gotEmail = r.Header.Get("X-User-Email")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
token := generateToken(validClaims(), jwtSecret)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if gotUserID != "test-user-id" {
|
||||
t.Fatalf("expected X-User-ID 'test-user-id', got '%s'", gotUserID)
|
||||
}
|
||||
if gotEmail != "test@multica.ai" {
|
||||
t.Fatalf("expected X-User-Email 'test@multica.ai', got '%s'", gotEmail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_MissingClaims(t *testing.T) {
|
||||
var gotUserID, gotEmail string
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUserID = r.Header.Get("X-User-ID")
|
||||
gotEmail = r.Header.Get("X-User-Email")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Token with no sub or email claims, only exp
|
||||
claims := jwt.MapClaims{
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
token := generateToken(claims, jwtSecret)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if gotUserID != "" {
|
||||
t.Fatalf("expected empty X-User-ID, got '%s'", gotUserID)
|
||||
}
|
||||
if gotEmail != "" {
|
||||
t.Fatalf("expected empty X-User-Email, got '%s'", gotEmail)
|
||||
}
|
||||
}
|
||||
177
server/internal/realtime/hub_test.go
Normal file
177
server/internal/realtime/hub_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package realtime
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func newTestHub(t *testing.T) (*Hub, *httptest.Server) {
|
||||
t.Helper()
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
HandleWebSocket(hub, w, r)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
return hub, server
|
||||
}
|
||||
|
||||
func connectWS(t *testing.T, server *httptest.Server) *websocket.Conn {
|
||||
t.Helper()
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect WebSocket: %v", err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
func TestHub_ClientRegistration(t *testing.T) {
|
||||
hub, server := newTestHub(t)
|
||||
defer server.Close()
|
||||
|
||||
conn := connectWS(t, server)
|
||||
defer conn.Close()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
hub.mu.RLock()
|
||||
count := len(hub.clients)
|
||||
hub.mu.RUnlock()
|
||||
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 client, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Broadcast(t *testing.T) {
|
||||
hub, server := newTestHub(t)
|
||||
defer server.Close()
|
||||
|
||||
conn1 := connectWS(t, server)
|
||||
defer conn1.Close()
|
||||
conn2 := connectWS(t, server)
|
||||
defer conn2.Close()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
msg := []byte(`{"type":"issue:created","data":"test"}`)
|
||||
hub.Broadcast(msg)
|
||||
|
||||
conn1.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, received1, err := conn1.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("client 1 read error: %v", err)
|
||||
}
|
||||
if string(received1) != string(msg) {
|
||||
t.Fatalf("client 1: expected %s, got %s", msg, received1)
|
||||
}
|
||||
|
||||
conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, received2, err := conn2.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("client 2 read error: %v", err)
|
||||
}
|
||||
if string(received2) != string(msg) {
|
||||
t.Fatalf("client 2: expected %s, got %s", msg, received2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ClientDisconnect(t *testing.T) {
|
||||
hub, server := newTestHub(t)
|
||||
defer server.Close()
|
||||
|
||||
conn := connectWS(t, server)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
hub.mu.RLock()
|
||||
countBefore := len(hub.clients)
|
||||
hub.mu.RUnlock()
|
||||
if countBefore != 1 {
|
||||
t.Fatalf("expected 1 client before disconnect, got %d", countBefore)
|
||||
}
|
||||
|
||||
conn.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
hub.mu.RLock()
|
||||
countAfter := len(hub.clients)
|
||||
hub.mu.RUnlock()
|
||||
if countAfter != 0 {
|
||||
t.Fatalf("expected 0 clients after disconnect, got %d", countAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_BroadcastToMultipleClients(t *testing.T) {
|
||||
hub, server := newTestHub(t)
|
||||
defer server.Close()
|
||||
|
||||
const numClients = 5
|
||||
conns := make([]*websocket.Conn, numClients)
|
||||
for i := 0; i < numClients; i++ {
|
||||
conns[i] = connectWS(t, server)
|
||||
defer conns[i].Close()
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
hub.mu.RLock()
|
||||
count := len(hub.clients)
|
||||
hub.mu.RUnlock()
|
||||
if count != numClients {
|
||||
t.Fatalf("expected %d clients, got %d", numClients, count)
|
||||
}
|
||||
|
||||
msg := []byte(`{"type":"test","count":5}`)
|
||||
hub.Broadcast(msg)
|
||||
|
||||
for i, conn := range conns {
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, received, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("client %d read error: %v", i, err)
|
||||
}
|
||||
if string(received) != string(msg) {
|
||||
t.Fatalf("client %d: expected %s, got %s", i, msg, received)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_MultipleBroadcasts(t *testing.T) {
|
||||
hub, server := newTestHub(t)
|
||||
defer server.Close()
|
||||
|
||||
conn := connectWS(t, server)
|
||||
defer conn.Close()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
messages := []string{
|
||||
`{"type":"issue:created"}`,
|
||||
`{"type":"issue:updated"}`,
|
||||
`{"type":"issue:deleted"}`,
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
hub.Broadcast([]byte(msg))
|
||||
}
|
||||
|
||||
for i, expected := range messages {
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, received, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("message %d read error: %v", i, err)
|
||||
}
|
||||
if string(received) != expected {
|
||||
t.Fatalf("message %d: expected %s, got %s", i, expected, received)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue