diff --git a/.oxlintrc.json b/.oxlintrc.json index 3a264b0..19dc1b6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -178,6 +178,17 @@ "env": { "vitest": true }, "rules": { "typescript/no-explicit-any": "off" } }, + { + "files": ["tests/e2e/**/*.ts"], + "env": { "browser": false, "node": true }, + "rules": { + "import/no-nodejs-modules": "off", + "no-console": "off", + "no-duplicate-imports": "off", + "no-process-exit": "off", + "promise/prefer-await-to-then": "off" + } + }, { "files": ["scripts/**/*.ts"], "env": { "browser": false, "node": true }, diff --git a/playwright.config.ts b/playwright.config.ts index 5982201..fea61eb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,10 +2,12 @@ import { defineConfig, devices } from "@playwright/test"; const port = process.env.PORT ?? "3100"; const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`; +const databaseFile = process.env.DATABASE_FILE ?? `/tmp/mal-e2e-${port}.db`; export default defineConfig({ testDir: "./tests/e2e", testMatch: "**/*.e2e.ts", + globalSetup: "./tests/e2e/global-setup.ts", timeout: 30_000, expect: { timeout: 5000 }, fullyParallel: true, @@ -14,7 +16,7 @@ export default defineConfig({ webServer: process.env.PLAYWRIGHT_BASE_URL === undefined ? { - command: `PORT=${port} DATABASE_FILE=/tmp/mal-e2e.db GIN_MODE=test go run ./cmd/server`, + command: `PORT=${port} DATABASE_FILE=${databaseFile} GIN_MODE=test go run ./cmd/server`, url: baseURL, reuseExistingServer: process.env.CI === undefined, timeout: 120_000, diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..727cd2e --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,24 @@ +import { spawnSync } from "node:child_process"; + +const port = process.env.PORT ?? "3100"; + +export default function globalSetup() { + if (process.env.PLAYWRIGHT_BASE_URL !== undefined) { + return; + } + + const username = process.env.E2E_USERNAME ?? "e2e@example.com"; + const password = process.env.E2E_PASSWORD ?? "e2e-password"; + const databaseFile = process.env.DATABASE_FILE ?? `/tmp/mal-e2e-${port}.db`; + + const result = spawnSync("go", ["run", "./cmd/user", username, password], { + env: { ...process.env, DATABASE_FILE: databaseFile, GIN_MODE: "test" }, + input: "y\n", + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf8", + }); + + if (result.status !== 0) { + throw new Error(`Failed to seed e2e user:\n${result.stderr || result.stdout}`); + } +} diff --git a/tests/e2e/smoke.e2e.ts b/tests/e2e/smoke.e2e.ts index dac6ec5..dcf7e25 100644 --- a/tests/e2e/smoke.e2e.ts +++ b/tests/e2e/smoke.e2e.ts @@ -1,5 +1,24 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "@playwright/test"; +const username = process.env.E2E_USERNAME ?? "e2e@example.com"; +const password = process.env.E2E_PASSWORD ?? "e2e-password"; + +const signIn = async (page: Page) => { + const response = await page.request.post("/login", { form: { username, password } }); + + expect(response.status()).toBeLessThan(400); +}; + +const signInThroughUI = async (page: Page) => { + await page.goto("/login"); + await page.getByRole("textbox", { name: /email address/iu }).fill(username); + await page.locator('input[name="password"]').fill(password); + await page.getByRole("button", { name: /sign in/iu }).click(); + await expect(page).toHaveURL(/\/$/u); +}; + test("redirects protected pages to login", async ({ page }) => { const response = await page.goto("/"); @@ -10,14 +29,169 @@ test("redirects protected pages to login", async ({ page }) => { test("renders the login page", async ({ page }) => { await page.goto("/login"); + await expect(page).toHaveTitle(/MyAnimeList: Login/u); await expect(page.getByRole("textbox", { name: /email address/iu })).toBeVisible(); await expect(page.locator('input[name="password"]')).toBeVisible(); await expect(page.getByRole("button", { name: /sign in/iu })).toBeVisible(); }); +test("shows an error for invalid credentials", async ({ page }) => { + await page.goto("/login"); + + await page.getByRole("textbox", { name: /email address/iu }).fill(username); + await page.locator('input[name="password"]').fill("wrong-password"); + await page.getByRole("button", { name: /sign in/iu }).click(); + + await expect(page).toHaveURL(/\/login$/u); + await expect(page.getByRole("alert")).toContainText(/invalid username or password/iu); +}); + +test("toggles password visibility on the login page", async ({ page }) => { + await page.goto("/login"); + + const passwordInput = page.locator('input[name="password"]'); + await expect(passwordInput).toHaveAttribute("type", "password"); + + await page.getByRole("button", { name: /show password/iu }).click(); + await expect(passwordInput).toHaveAttribute("type", "text"); + await expect(page.getByRole("button", { name: /hide password/iu })).toBeVisible(); + + await page.getByRole("button", { name: /hide password/iu }).click(); + await expect(passwordInput).toHaveAttribute("type", "password"); +}); + +test("signs in and renders authenticated home navigation", async ({ page }) => { + const airingResponse = (async () => { + try { + return await page.waitForResponse( + (response) => response.url().includes("/api/catalog/airing"), + { timeout: 10_000 }, + ); + } catch { + return undefined; + } + })(); + + await signInThroughUI(page); + + const primaryNavigation = page.getByRole("navigation", { name: /primary navigation/iu }); + await expect(page).toHaveTitle(/MyAnimeList: Home/u); + await expect(primaryNavigation).toBeVisible(); + await expect(primaryNavigation.getByRole("link", { name: "Browse", exact: true })).toBeVisible(); + await expect( + primaryNavigation.getByRole("link", { name: "Top Picks", exact: true }), + ).toBeVisible(); + await expect( + primaryNavigation.getByRole("link", { name: "Watchlist", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("heading", { name: /airing & popular/iu })).toBeVisible(); + await airingResponse; +}); + +test("renders login page even with an existing session", async ({ page }) => { + await signIn(page); + + const response = await page.goto("/login"); + + expect(response?.status()).toBe(200); + await expect(page).toHaveURL(/\/login$/u); + await expect(page.getByRole("button", { name: /sign in/iu })).toBeVisible(); +}); + +test("renders the empty watchlist state", async ({ page }) => { + await signIn(page); + + await page.goto("/watchlist"); + + await expect(page).toHaveURL(/\/watchlist$/u); + await expect(page.getByRole("heading", { name: /^watchlist$/iu })).toBeVisible(); + await expect(page.getByText(/your watchlist is empty/iu)).toBeVisible(); + await expect(page.getByRole("button", { name: /^all$/iu })).toBeVisible(); + await expect(page.getByRole("button", { name: /watching/iu })).toBeVisible(); + await expect(page.getByRole("link", { name: /go home/iu })).toBeVisible(); +}); + +test("renders the authenticated search page", async ({ page }) => { + await signIn(page); + + await page.goto("/search"); + + await expect(page).toHaveURL(/\/search$/u); + await expect(page).toHaveTitle(/MyAnimeList: Search/u); + await expect(page.getByRole("searchbox", { name: /search anime/iu })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: /search anime/iu })).toHaveAttribute( + "placeholder", + "Search anime...", + ); +}); + +test("logs out and protects pages again", async ({ page }) => { + await signIn(page); + + await page.goto("/logout"); + await expect(page).toHaveURL(/\/login$/u); + + await page.goto("/watchlist"); + await expect(page).toHaveURL(/\/login$/u); +}); + +test("returns an API token for valid credentials", async ({ request }) => { + const response = await request.post("/api/auth/login", { + data: { username, password, name: "playwright" }, + }); + + expect(response.status()).toBe(200); + const body = (await response.json()) as { token?: string; user?: { username?: string } }; + expect(body.token).toEqual(expect.any(String)); + expect(body.user?.username).toBe(username); +}); + +test("rejects invalid API login payloads and credentials", async ({ request }) => { + const missingPassword = await request.post("/api/auth/login", { data: { username } }); + expect(missingPassword.status()).toBe(400); + + const wrongPassword = await request.post("/api/auth/login", { + data: { username, password: "wrong-password" }, + }); + expect(wrongPassword.status()).toBe(401); +}); + +test("protects API routes without a valid session", async ({ request }) => { + const response = await request.get("/api/search-quick?q="); + + expect(response.status()).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: "Unauthorized" }); +}); + +test("allows authenticated API requests with a bearer token", async ({ request }) => { + const login = await request.post("/api/auth/login", { + data: { username, password, name: "playwright-api" }, + }); + const body = (await login.json()) as { token: string }; + + const response = await request.get("/api/search-quick?q=", { + headers: { Authorization: `Bearer ${body.token}` }, + }); + + expect(response.status()).toBe(200); + await expect(response.json()).resolves.toEqual([]); +}); + test("serves static assets without authentication", async ({ request }) => { const response = await request.get("/dist/tailwind.css"); expect(response.status()).toBe(200); expect(response.headers()["content-type"]).toContain("text/css"); }); + +test("serves app assets and the manifest without authentication", async ({ request }) => { + const [appScript, manifest] = await Promise.all([ + request.get("/dist/static/app.js"), + request.get("/static/assets/manifest.json"), + ]); + + expect(appScript.status()).toBe(200); + expect(appScript.headers()["content-type"]).toContain("javascript"); + expect(manifest.status()).toBe(200); + expect(manifest.headers()["content-type"]).toContain("application/json"); +});