mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Merge pull request #639 from jyf2100/agent/agent/e7cb5f8c
test(web): cover issue creation flow regressions
This commit is contained in:
13
e2e/env.ts
Normal file
13
e2e/env.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { config } from "dotenv";
|
||||
|
||||
const envCandidates = [".env.worktree", ".env"];
|
||||
|
||||
for (const filename of envCandidates) {
|
||||
const path = resolve(process.cwd(), filename);
|
||||
if (existsSync(path)) {
|
||||
config({ path });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
|
||||
*/
|
||||
|
||||
import "./env";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
|
||||
@@ -21,39 +22,43 @@ export class TestApiClient {
|
||||
private createdIssueIds: string[] = [];
|
||||
|
||||
async login(email: string, name: string) {
|
||||
// Step 1: Send verification code
|
||||
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
// Rate limited — code already sent recently, read it from DB
|
||||
if (sendRes.status !== 429) {
|
||||
throw new Error(`send-code failed: ${sendRes.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Read code from database
|
||||
const client = new pg.Client(DATABASE_URL);
|
||||
await client.connect();
|
||||
try {
|
||||
// Keep each E2E login isolated so previous test runs do not trip the
|
||||
// per-email send-code rate limit.
|
||||
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
|
||||
|
||||
// Step 1: Send verification code
|
||||
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
throw new Error(`send-code failed: ${sendRes.status}`);
|
||||
}
|
||||
|
||||
// Step 2: Read code from database
|
||||
const result = await client.query(
|
||||
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
|
||||
[email]
|
||||
[email],
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`No verification code found for ${email}`);
|
||||
}
|
||||
const code = result.rows[0].code;
|
||||
|
||||
// Step 3: Verify code to get JWT
|
||||
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, code }),
|
||||
body: JSON.stringify({ email, code: result.rows[0].code }),
|
||||
});
|
||||
if (!verifyRes.ok) {
|
||||
throw new Error(`verify-code failed: ${verifyRes.status}`);
|
||||
}
|
||||
const data = await verifyRes.json();
|
||||
|
||||
this.token = data.token;
|
||||
|
||||
// Update user name if needed
|
||||
@@ -64,6 +69,8 @@ export class TestApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
|
||||
|
||||
return data;
|
||||
} finally {
|
||||
await client.end();
|
||||
|
||||
@@ -11,11 +11,14 @@ test.describe("Issues", () => {
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup();
|
||||
if (api) {
|
||||
await api.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("issues page loads with board view", async ({ page }) => {
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
await api.createIssue("E2E Board View " + Date.now());
|
||||
await page.reload();
|
||||
|
||||
// Board columns should be visible
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
@@ -23,29 +26,36 @@ test.describe("Issues", () => {
|
||||
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();
|
||||
test("can switch from board to list view", async ({ page }) => {
|
||||
const title = "E2E List Switch " + Date.now();
|
||||
await api.createIssue(title);
|
||||
await page.reload();
|
||||
await expect(page.locator("text=Backlog")).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();
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
});
|
||||
|
||||
test("can create a new issue", async ({ page }) => {
|
||||
await page.click("text=New Issue");
|
||||
const newIssueButton = page.getByRole("button", { name: "New Issue" });
|
||||
await expect(newIssueButton).toBeVisible();
|
||||
await newIssueButton.click();
|
||||
|
||||
const title = "E2E Created " + Date.now();
|
||||
await page.fill('input[placeholder="Issue title..."]', title);
|
||||
await page.click("text=Create");
|
||||
const titleInput = page.getByRole("textbox", { name: "Issue title" });
|
||||
await expect(titleInput).toBeVisible();
|
||||
await titleInput.fill(title);
|
||||
await page.getByRole("button", { name: "Create Issue" }).click();
|
||||
|
||||
// New issue should appear on the page
|
||||
await expect(page.locator(`text=${title}`).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Issue created")).toBeVisible({ timeout: 10000 });
|
||||
await expect(
|
||||
page.getByRole("region", { name: /Notifications/ }).getByText(title),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "View issue" }).click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate to issue detail page", async ({ page }) => {
|
||||
@@ -54,7 +64,6 @@ test.describe("Issues", () => {
|
||||
|
||||
// Reload to see the new issue
|
||||
await page.reload();
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Navigate to the issue detail
|
||||
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
|
||||
@@ -71,18 +80,15 @@ test.describe("Issues", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("can cancel issue creation", async ({ page }) => {
|
||||
await page.click("text=New Issue");
|
||||
test("can dismiss issue creation", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "New Issue" }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('input[placeholder="Issue title..."]'),
|
||||
).toBeVisible();
|
||||
const titleInput = page.getByRole("textbox", { name: "Issue title" });
|
||||
await expect(titleInput).toBeVisible();
|
||||
|
||||
await page.click("text=Cancel");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(
|
||||
page.locator('input[placeholder="Issue title..."]'),
|
||||
).not.toBeVisible();
|
||||
await expect(page.locator("text=New Issue")).toBeVisible();
|
||||
await expect(titleInput).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
221
packages/views/modals/create-issue.test.tsx
Normal file
221
packages/views/modals/create-issue.test.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const mockPush = vi.hoisted(() => vi.fn());
|
||||
const mockCreateIssue = vi.hoisted(() => vi.fn());
|
||||
const mockSetDraft = vi.hoisted(() => vi.fn());
|
||||
const mockClearDraft = vi.hoisted(() => vi.fn());
|
||||
const mockToastCustom = vi.hoisted(() => vi.fn());
|
||||
const mockToastDismiss = vi.hoisted(() => vi.fn());
|
||||
const mockToastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockDraftStore = {
|
||||
draft: {
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo" as const,
|
||||
priority: "none" as const,
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
dueDate: null,
|
||||
},
|
||||
setDraft: mockSetDraft,
|
||||
clearDraft: mockClearDraft,
|
||||
};
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace", () => ({
|
||||
useWorkspaceStore: Object.assign(
|
||||
(selector?: (state: { workspace: { name: string } }) => unknown) => {
|
||||
const state = { workspace: { name: "Test Workspace" } };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ workspace: { name: "Test Workspace" } }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores/draft-store", () => ({
|
||||
useIssueDraftStore: Object.assign(
|
||||
(selector?: (state: typeof mockDraftStore) => unknown) =>
|
||||
(selector ? selector(mockDraftStore) : mockDraftStore),
|
||||
{ getState: () => mockDraftStore },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/mutations", () => ({
|
||||
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks/use-file-upload", () => ({
|
||||
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {},
|
||||
}));
|
||||
|
||||
vi.mock("../editor", () => ({
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onUpdate?.(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => {
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSubmit?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../issues/components", () => ({
|
||||
StatusIcon: ({ status }: { status: string }) => <span data-testid="status-icon">{status}</span>,
|
||||
StatusPicker: () => <div data-testid="status-picker" />,
|
||||
PriorityPicker: () => <div data-testid="priority-picker" />,
|
||||
AssigneePicker: () => <div data-testid="assignee-picker" />,
|
||||
DueDatePicker: () => <div data-testid="due-date-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock("../projects/components/project-picker", () => ({
|
||||
ProjectPicker: () => <div data-testid="project-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => <div data-testid="dialog-root">{children}</div>,
|
||||
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
type = "button",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit" | "reset";
|
||||
}) => (
|
||||
<button type={type} disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
|
||||
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
|
||||
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
|
||||
Upload file
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/lib/utils", () => ({
|
||||
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
custom: mockToastCustom,
|
||||
dismiss: mockToastDismiss,
|
||||
error: mockToastError,
|
||||
},
|
||||
}));
|
||||
|
||||
import { CreateIssueModal } from "./create-issue";
|
||||
|
||||
describe("CreateIssueModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateIssue.mockResolvedValue({
|
||||
id: "issue-123",
|
||||
identifier: "TES-123",
|
||||
title: "Ship create issue regression coverage",
|
||||
status: "todo",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success feedback with a direct path to the new issue", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(<CreateIssueModal onClose={onClose} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Issue title"), " Ship create issue regression coverage ");
|
||||
await user.click(screen.getByRole("button", { name: "Create Issue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: "Ship create issue regression coverage",
|
||||
description: undefined,
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assignee_type: undefined,
|
||||
assignee_id: undefined,
|
||||
due_date: undefined,
|
||||
attachment_ids: undefined,
|
||||
parent_issue_id: undefined,
|
||||
project_id: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockClearDraft).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(mockToastCustom).toHaveBeenCalledTimes(1);
|
||||
|
||||
const renderToast = mockToastCustom.mock.calls[0]?.[0];
|
||||
expect(typeof renderToast).toBe("function");
|
||||
|
||||
render(renderToast("toast-1"));
|
||||
|
||||
expect(screen.getByText("Issue created")).toBeInTheDocument();
|
||||
expect(screen.getByText(/TES-123/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Ship create issue regression coverage/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "View issue" }));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/issues/issue-123");
|
||||
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./e2e/env";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
Reference in New Issue
Block a user