diff --git a/e2e/env.ts b/e2e/env.ts new file mode 100644 index 000000000..435f6b269 --- /dev/null +++ b/e2e/env.ts @@ -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; + } +} diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 524124c36..e972fe47e 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -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(); diff --git a/e2e/issues.spec.ts b/e2e/issues.spec.ts index 2f9403c4c..48b4d4ffa 100644 --- a/e2e/issues.spec.ts +++ b/e2e/issues.spec.ts @@ -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(); }); }); diff --git a/packages/views/modals/create-issue.test.tsx b/packages/views/modals/create-issue.test.tsx new file mode 100644 index 000000000..a8f430b1b --- /dev/null +++ b/packages/views/modals/create-issue.test.tsx @@ -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 ( +