diff --git a/package.json b/package.json index 7d8eeb873..a1f405efe 100644 --- a/package.json +++ b/package.json @@ -51,11 +51,13 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^25.9.3", + "@types/pg": "^8.20.0", "@vitest/coverage-v8": "^4.1.9", "happy-dom": "^20.10.5", "knip": "^6.17.1", "lefthook": "^2.1.9", "npm-check-updates": "^22.2.3", + "pg": "^8.21.0", "turbo": "^2.9.18", "typescript": "^6.0.3", "vitest": "^4.1.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 669cc4c07..5a7f66f17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@types/node': specifier: ^25.9.3 version: 25.9.3 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@vitest/coverage-v8': specifier: ^4.1.9 version: 4.1.9(vitest@4.1.9) @@ -63,6 +66,9 @@ importers: npm-check-updates: specifier: ^22.2.3 version: 22.2.3 + pg: + specifier: ^8.21.0 + version: 8.21.0 turbo: specifier: ^2.9.18 version: 2.9.18 diff --git a/tests/e2e/fixtures/auth.ts b/tests/e2e/fixtures/auth.ts new file mode 100644 index 000000000..b4123ca53 --- /dev/null +++ b/tests/e2e/fixtures/auth.ts @@ -0,0 +1,66 @@ +import type { APIRequestContext, Browser, BrowserContext, Page } from "@playwright/test"; +import type { E2EAccount } from "./data"; + +async function assertAuthResponse(response: Awaited>) { + if (response.ok()) return; + + throw new Error(`Authentication request failed with ${response.status()}: ${await response.text()}`); +} + +export async function registerViaUi(page: Page, account: E2EAccount) { + await page.goto("/auth/register"); + await page.getByLabel("Name").fill(account.name); + await page.getByLabel("Username").fill(account.username); + await page.getByLabel("Email Address").fill(account.email); + await page.getByLabel("Password").fill(account.password); + await page.getByRole("button", { name: "Sign up" }).click(); + await page.getByRole("link", { name: "Continue" }).click(); + await page.waitForURL(/\/dashboard/); +} + +export async function loginViaUi(page: Page, account: E2EAccount) { + await page.goto("/auth/login"); + await page.getByLabel("Email Address").fill(account.email); + await page.getByLabel("Password").fill(account.password); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL(/\/dashboard/); +} + +export async function logoutViaUi(page: Page, account: E2EAccount) { + await page.getByText(account.email).click(); + await page.getByRole("menuitem", { name: "Logout" }).click(); + await page.goto("/auth/login"); +} + +export async function registerViaApi(request: APIRequestContext, account: E2EAccount, baseURL: string) { + const response = await request.post("/api/auth/sign-up/email", { + headers: { + origin: baseURL, + referer: `${baseURL}/auth/register`, + }, + data: { + name: account.name, + email: account.email, + password: account.password, + username: account.username, + displayUsername: account.username, + callbackURL: "/dashboard", + }, + }); + + await assertAuthResponse(response); +} + +export async function createAuthenticatedContext( + browser: Browser, + request: APIRequestContext, + account: E2EAccount, + baseURL: string, +): Promise { + await registerViaApi(request, account, baseURL); + + return browser.newContext({ + baseURL, + storageState: await request.storageState(), + }); +} diff --git a/tests/e2e/fixtures/data.ts b/tests/e2e/fixtures/data.ts new file mode 100644 index 000000000..6fcbe643e --- /dev/null +++ b/tests/e2e/fixtures/data.ts @@ -0,0 +1,37 @@ +import type { TestInfo } from "@playwright/test"; + +const sanitize = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +export type E2EAccount = { + name: string; + username: string; + email: string; + password: string; +}; + +export function createRunSlug(testInfo: TestInfo) { + const worker = testInfo.workerIndex; + const title = sanitize(testInfo.titlePath.join("-")).slice(0, 32); + const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + + return `e2e-${worker}-${title}-${suffix}`; +} + +export function createAccount(testInfo: TestInfo): E2EAccount { + const username = createRunSlug(testInfo).replaceAll("-", "_").slice(0, 64); + + return { + name: "E2E Test User", + username, + email: `${username}@example.test`, + password: "Password123!", + }; +} + +export function createResumeName(testInfo: TestInfo) { + return `E2E Resume ${createRunSlug(testInfo)}`; +} diff --git a/tests/e2e/fixtures/db.ts b/tests/e2e/fixtures/db.ts new file mode 100644 index 000000000..d221be760 --- /dev/null +++ b/tests/e2e/fixtures/db.ts @@ -0,0 +1,19 @@ +import type { E2EAccount } from "./data"; +import { Pool } from "pg"; + +function getDatabaseUrl() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error("DATABASE_URL is required for E2E cleanup."); + + return databaseUrl; +} + +export async function deleteE2EUser(account: E2EAccount) { + const pool = new Pool({ connectionString: getDatabaseUrl() }); + + try { + await pool.query('delete from "user" where email = $1 or username = $2', [account.email, account.username]); + } finally { + await pool.end(); + } +} diff --git a/tests/e2e/fixtures/resume.ts b/tests/e2e/fixtures/resume.ts new file mode 100644 index 000000000..042afc597 --- /dev/null +++ b/tests/e2e/fixtures/resume.ts @@ -0,0 +1,29 @@ +import type { Page, TestInfo } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { createResumeName } from "./data"; + +export async function createSampleResumeFromDashboard(page: Page, testInfo: TestInfo) { + const resumeName = createResumeName(testInfo); + + await page.goto("/dashboard/resumes"); + await page.getByText("Create a new resume").click(); + + const dialog = page.getByRole("dialog", { name: "Create a new resume" }); + await dialog.getByLabel("Name").fill(resumeName); + + const createGroup = dialog.getByRole("group", { name: "Create resume with options" }); + await createGroup.getByRole("button").nth(1).click(); + await page.getByRole("menuitem", { name: "Create a Sample Resume" }).click(); + + const resumeLink = page.getByRole("link", { name: new RegExp(resumeName) }); + await expect(resumeLink).toBeVisible(); + await resumeLink.click(); + await page.waitForURL(/\/builder\/.+/); + + return resumeName; +} + +export async function openSidebarSection(page: Page, title: string) { + await page.getByTitle(title, { exact: true }).click(); + await expect(page.getByRole("heading", { name: title })).toBeVisible(); +} diff --git a/tests/e2e/fixtures/test.ts b/tests/e2e/fixtures/test.ts new file mode 100644 index 000000000..85891132e --- /dev/null +++ b/tests/e2e/fixtures/test.ts @@ -0,0 +1,46 @@ +import type { BrowserContext, Page } from "@playwright/test"; +import type { E2EAccount } from "./data"; +import { test as base, expect } from "@playwright/test"; +import { createAuthenticatedContext } from "./auth"; +import { createAccount } from "./data"; +import { deleteE2EUser } from "./db"; + +type Fixtures = { + account: E2EAccount; + authContext: BrowserContext; + authPage: Page; +}; + +export const test = base.extend({ + account: async ({ baseURL }, use, testInfo) => { + void baseURL; + const account = createAccount(testInfo); + + try { + await use(account); + } finally { + await deleteE2EUser(account); + } + }, + authContext: async ({ browser, request, account }, use, testInfo) => { + const baseURL = String(testInfo.project.use.baseURL ?? "http://localhost:3000"); + const context = await createAuthenticatedContext(browser, request, account, baseURL); + + try { + await use(context); + } finally { + await context.close(); + } + }, + authPage: async ({ authContext }, use) => { + const page = await authContext.newPage(); + + try { + await use(page); + } finally { + await page.close(); + } + }, +}); + +export { expect }; diff --git a/tests/e2e/specs/auth.spec.ts b/tests/e2e/specs/auth.spec.ts new file mode 100644 index 000000000..65ea08953 --- /dev/null +++ b/tests/e2e/specs/auth.spec.ts @@ -0,0 +1,13 @@ +import { loginViaUi, logoutViaUi, registerViaUi } from "../fixtures/auth"; +import { expect, test } from "../fixtures/test"; + +test("registers and logs in with email credentials", async ({ page, account }) => { + await registerViaUi(page, account); + await expect(page.getByRole("heading", { name: "Resumes" })).toBeVisible(); + + await logoutViaUi(page, account); + await expect(page.getByRole("heading", { name: "Sign in to your account" })).toBeVisible(); + + await loginViaUi(page, account); + await expect(page.getByRole("heading", { name: "Resumes" })).toBeVisible(); +}); diff --git a/tests/e2e/specs/json-export-import.spec.ts b/tests/e2e/specs/json-export-import.spec.ts new file mode 100644 index 000000000..c9d4ae10a --- /dev/null +++ b/tests/e2e/specs/json-export-import.spec.ts @@ -0,0 +1,28 @@ +import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +test("exports and imports a resume JSON backup", async ({ authPage: page }, testInfo) => { + const resumeName = await createSampleResumeFromDashboard(page, testInfo); + + await openSidebarSection(page, "Export"); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: /^JSON/ }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/\.json$/); + + const downloadPath = await download.path(); + if (!downloadPath) throw new Error("Expected Playwright to provide a downloaded JSON path."); + + await page.goto("/dashboard/resumes"); + await page.getByText("Import an existing resume").click(); + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: "Reactive Resume (JSON)" }).click(); + await page.locator('input[type="file"]').setInputFiles(downloadPath); + await page.getByRole("button", { name: "Import" }).click(); + + await page.waitForURL(/\/builder\/.+/); + await openSidebarSection(page, "Basics"); + await expect(page.getByLabel("Name")).toHaveValue(/.+/); + await expect(page.getByText(resumeName)).toBeVisible(); +}); diff --git a/tests/e2e/specs/public-sharing.spec.ts b/tests/e2e/specs/public-sharing.spec.ts new file mode 100644 index 000000000..34849e7c5 --- /dev/null +++ b/tests/e2e/specs/public-sharing.spec.ts @@ -0,0 +1,16 @@ +import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +test("publishes a resume and renders it for an anonymous visitor", async ({ browser, authPage: page }, testInfo) => { + await createSampleResumeFromDashboard(page, testInfo); + await openSidebarSection(page, "Sharing"); + + await page.getByLabel("Allow Public Access").click(); + const publicUrl = await page.getByLabel("URL").inputValue(); + expect(publicUrl).toMatch(/\/e2e_/); + + const anonymous = await browser.newPage(); + await anonymous.goto(publicUrl); + await expect(anonymous.getByRole("button", { name: "Download PDF" })).toBeVisible(); + await anonymous.close(); +}); diff --git a/tests/e2e/specs/resume-lifecycle.spec.ts b/tests/e2e/specs/resume-lifecycle.spec.ts new file mode 100644 index 000000000..0c64c240b --- /dev/null +++ b/tests/e2e/specs/resume-lifecycle.spec.ts @@ -0,0 +1,14 @@ +import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +test("creates a sample resume and persists a basics edit", async ({ authPage: page }, testInfo) => { + await createSampleResumeFromDashboard(page, testInfo); + + const updatedName = `E2E Edited ${Date.now()}`; + await openSidebarSection(page, "Basics"); + await page.getByLabel("Name").fill(updatedName); + + await page.reload(); + await openSidebarSection(page, "Basics"); + await expect(page.getByLabel("Name")).toHaveValue(updatedName); +});