test(e2e): add global setup, sign-in helpers and authenticated page tests
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 8m45s
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 8m45s
This commit is contained in:
@@ -178,6 +178,17 @@
|
|||||||
"env": { "vitest": true },
|
"env": { "vitest": true },
|
||||||
"rules": { "typescript/no-explicit-any": "off" }
|
"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"],
|
"files": ["scripts/**/*.ts"],
|
||||||
"env": { "browser": false, "node": true },
|
"env": { "browser": false, "node": true },
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
|
|
||||||
const port = process.env.PORT ?? "3100";
|
const port = process.env.PORT ?? "3100";
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`;
|
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({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
testMatch: "**/*.e2e.ts",
|
testMatch: "**/*.e2e.ts",
|
||||||
|
globalSetup: "./tests/e2e/global-setup.ts",
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
expect: { timeout: 5000 },
|
expect: { timeout: 5000 },
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
@@ -14,7 +16,7 @@ export default defineConfig({
|
|||||||
webServer:
|
webServer:
|
||||||
process.env.PLAYWRIGHT_BASE_URL === undefined
|
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,
|
url: baseURL,
|
||||||
reuseExistingServer: process.env.CI === undefined,
|
reuseExistingServer: process.env.CI === undefined,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
24
tests/e2e/global-setup.ts
Normal file
24
tests/e2e/global-setup.ts
Normal file
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
import { expect, test } 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 }) => {
|
test("redirects protected pages to login", async ({ page }) => {
|
||||||
const response = await page.goto("/");
|
const response = await page.goto("/");
|
||||||
|
|
||||||
@@ -10,14 +29,169 @@ test("redirects protected pages to login", async ({ page }) => {
|
|||||||
test("renders the login page", async ({ page }) => {
|
test("renders the login page", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle(/MyAnimeList: Login/u);
|
||||||
await expect(page.getByRole("textbox", { name: /email address/iu })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: /email address/iu })).toBeVisible();
|
||||||
await expect(page.locator('input[name="password"]')).toBeVisible();
|
await expect(page.locator('input[name="password"]')).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /sign in/iu })).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 }) => {
|
test("serves static assets without authentication", async ({ request }) => {
|
||||||
const response = await request.get("/dist/tailwind.css");
|
const response = await request.get("/dist/tailwind.css");
|
||||||
|
|
||||||
expect(response.status()).toBe(200);
|
expect(response.status()).toBe(200);
|
||||||
expect(response.headers()["content-type"]).toContain("text/css");
|
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");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user