diff --git a/backend/src/db.ts b/backend/src/db.ts index 9190de1..93b414d 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -9,5 +9,6 @@ export async function connectToDatabase() { console.log("Connected to MongoDB"); - return client.db("artex"); + // Use the normal Artex database unless another database is explicitly configured. + return client.db(process.env.MONGODB_DB ?? "artex"); } diff --git a/backend/src/seedE2eDatabase.ts b/backend/src/seedE2eDatabase.ts new file mode 100644 index 0000000..5fd02ed --- /dev/null +++ b/backend/src/seedE2eDatabase.ts @@ -0,0 +1,62 @@ +import "dotenv/config"; +import { MongoClient } from "mongodb"; + +const mongoUri = process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017"; + +const sourceDatabaseName = process.env.E2E_SOURCE_DB ?? "artex"; +const targetDatabaseName = process.env.MONGODB_DB ?? "artex_e2e_test"; + +const ARTWORK_LIMIT = 50; + +const client = new MongoClient(mongoUri); + +async function seedE2eDatabase() { + if (sourceDatabaseName === targetDatabaseName) { + throw new Error( + "E2E source and target database cannot be the same database.", + ); + } + + try { + await client.connect(); + + const sourceDatabase = client.db(sourceDatabaseName); + const targetDatabase = client.db(targetDatabaseName); + + const sourceArtworks = sourceDatabase.collection("met_artworks"); + const targetArtworks = targetDatabase.collection("met_artworks"); + + // Start every E2E run without data created by previous test runs. + await Promise.all([ + targetDatabase.collection("users").deleteMany({}), + targetDatabase.collection("galleries").deleteMany({}), + targetArtworks.deleteMany({}), + ]); + + // Copy a small deterministic subset of real artwork data for the E2E tests. + const artworks = await sourceArtworks + .find({}, { projection: { _id: 0 } }) + .sort({ id: 1 }) + .limit(ARTWORK_LIMIT) + .toArray(); + + if (artworks.length === 0) { + throw new Error( + `No artworks found in source database "${sourceDatabaseName}".`, + ); + } + + await targetArtworks.insertMany(artworks); + + console.log( + `Seeded "${targetDatabaseName}" with ${artworks.length} artworks from "${sourceDatabaseName}".`, + ); + } finally { + await client.close(); + } +} + +seedE2eDatabase().catch((error: unknown) => { + console.error("Failed to seed E2E database:", error); + process.exitCode = 1; +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..54493a2 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from "@playwright/test"; + +test.describe("authentication", () => { + test.beforeEach(async ({ page }) => { + // Start each auth test without a persisted session. + await page.goto("/"); + + await page.evaluate(() => { + sessionStorage.clear(); + }); + }); + + test("redirects unauthenticated users from profile to login", async ({ + page, + }) => { + await page.goto("/profile"); + + // Protected routes should redirect users without an auth token. + await expect(page).toHaveURL(/\/login$/); + + await expect( + page.getByRole("heading", { + name: "Login", + level: 1, + }), + ).toBeVisible(); + + await expect(page.getByLabel("Username")).toBeVisible(); + await expect(page.getByLabel("Password")).toBeVisible(); + }); + + test("shows an error when login credentials are invalid", async ({ + page, + }) => { + await page.goto("/login"); + + const loginButton = page.getByRole("button", { + name: "Login", + exact: true, + }); + + const loginForm = loginButton.locator("xpath=ancestor::form"); + + const usernameInput = loginForm.getByLabel("Username"); + const passwordInput = loginForm.getByLabel("Password"); + + // Use credentials that should not belong to a real account. + await usernameInput.fill(`invalid-user-${Date.now()}`); + await passwordInput.fill("DefinitelyWrong123!"); + + await expect(usernameInput).not.toHaveValue(""); + await expect(passwordInput).not.toHaveValue(""); + + await loginButton.click(); + + // The login attempt should fail without navigating away. + await expect(page).toHaveURL(/\/login$/); + + await expect(page.getByRole("alert")).toBeVisible(); + + // The form should become usable again after the failed request. + await expect( + page.getByRole("button", { + name: "Login", + exact: true, + }), + ).toBeEnabled(); + }); +}); diff --git a/e2e/core-flow.spec.ts b/e2e/core-flow.spec.ts new file mode 100644 index 0000000..d26ddd3 --- /dev/null +++ b/e2e/core-flow.spec.ts @@ -0,0 +1,278 @@ +import { test, expect } from "@playwright/test"; + +test("core user flow", async ({ page }) => { + // The complete flow performs several frontend, backend and database operations. + test.setTimeout(60_000); + + // Unique values prevent conflicts when the test is run multiple times. + const runId = Date.now().toString().slice(-8); + const username = `e2e${runId}`; + const password = "Playwright123!"; + const galleryTitle = `E2E Gallery ${runId}`; + + await page.goto("/"); + + const artworkResults = page.getByRole("list", { + name: "Artwork results", + }); + + await expect(artworkResults).toBeVisible(); + + // Use an artwork that already exists in the database instead of hardcoding a title that might change later. + const firstArtworkHeading = artworkResults + .getByRole("heading", { level: 3 }) + .first(); + + await expect(firstArtworkHeading).toBeVisible(); + + const artworkTitle = (await firstArtworkHeading.textContent())?.trim(); + + if (!artworkTitle) { + throw new Error("Could not find an artwork title to use in the E2E test."); + } + + // Search for the selected artwork. + const searchInput = page.getByLabel("Search artworks by title or artist"); + + await searchInput.fill(artworkTitle); + await page.getByRole("button", { name: "Search artworks" }).click(); + + // Verify that the search is reflected in the URL. + await expect + .poll(() => new URL(page.url()).searchParams.get("search")) + .toBe(artworkTitle); + + await expect( + artworkResults.getByRole("heading", { + name: artworkTitle, + exact: true, + }), + ).toBeVisible(); + + // Open the filter panel before accessing the sort controls. + await page.getByRole("button", { name: "Show filters" }).click(); + + // Open the sort section inside the filter panel. + await page.getByRole("button", { name: "Sort" }).click(); + + // React Select uses an internal input, so keyboard interaction is more reliable. + const sortSelect = page.getByLabel("Sort by"); + + await sortSelect.focus(); + await sortSelect.press("ArrowDown"); + + await page.getByRole("option", { name: "Title: A-Z" }).click(); + + // Verify that the selected sorting option is reflected in the URL. + await expect + .poll(() => new URL(page.url()).searchParams.get("sort")) + .toBe("TITLE_ASC"); + + // Open the artwork detail page. + await artworkResults + .getByRole("heading", { + name: artworkTitle, + exact: true, + }) + .click(); + + await expect(page).toHaveURL(/\/artworks\/\d+/); + + await expect( + page.getByRole("heading", { + name: artworkTitle, + exact: true, + level: 1, + }), + ).toBeVisible(); + + await expect( + page.getByRole("button", { + name: "Add artwork to gallery", + }), + ).toBeVisible(); + + // Go back to the catalogue and create a new account through the UI. + await page.getByRole("link", { name: "← Back" }).click(); + + // Use the desktop login link specifically because the page contains multiple links with the same accessible name. + await page + .getByLabel("Explore Artex") + .getByRole("link", { name: "Open login" }) + .click(); + + await expect(page).toHaveURL(/\/login$/); + + await page.getByRole("link", { name: "Create account" }).click(); + + await expect(page).toHaveURL(/\/create-account$/); + + const createAccountButton = page.getByRole("button", { + name: "Create account", + exact: true, + }); + + await expect(createAccountButton).toBeEnabled(); + + // Scope all registration fields to the exact form containing the submit button. + const createAccountForm = createAccountButton.locator("xpath=ancestor::form"); + + const usernameInput = createAccountForm.getByLabel("Username"); + const passwordInput = createAccountForm.getByLabel("Password"); + + await usernameInput.fill(username); + await passwordInput.fill(password); + + // Verify that the values are present in this exact form. + await expect(usernameInput).toHaveValue(username); + await expect(passwordInput).toHaveValue(password); + + const formIsValid = await createAccountForm.evaluate( + (form: HTMLFormElement) => form.checkValidity(), + ); + + expect(formIsValid).toBe(true); + + await createAccountButton.click(); + + // If the submit handler runs, the button should enter its loading state. + await expect( + page.getByRole("button", { + name: "Creating account...", + exact: true, + }), + ).toBeVisible({ + timeout: 5_000, + }); + + // Successful registration should redirect to the profile. + // If registration fails, the application should display an alert instead. + await expect( + page + .getByRole("heading", { name: "Your Gallery" }) + .or(page.getByRole("alert")), + ).toBeVisible({ + timeout: 10_000, + }); + + await expect(page).toHaveURL(/\/profile$/); + + await expect( + page.getByRole("heading", { + name: "Your Gallery", + }), + ).toBeVisible(); + + // Create a gallery for the artwork. + await page.getByRole("button", { name: "Create gallery" }).click(); + + await expect( + page.getByRole("heading", { + name: "Create a new gallery", + }), + ).toBeVisible(); + + await page.getByPlaceholder("Enter gallery title").fill(galleryTitle); + + await page + .getByRole("button", { + name: "Create", + exact: true, + }) + .click(); + + // Wait until the newly created gallery has been fetched and rendered. + const galleryLink = page.getByRole("link").filter({ + hasText: galleryTitle, + }); + + await expect(galleryLink).toBeVisible(); + + // Return to the catalogue while still logged in. + await page.getByRole("link", { name: "← Back" }).click(); + + await expect(page).toHaveURL(/\/$/); + + // Search for the same artwork again. + await page + .getByLabel("Search artworks by title or artist") + .fill(artworkTitle); + + await page.getByRole("button", { name: "Search artworks" }).click(); + + await expect( + page.getByRole("list", { name: "Artwork results" }).getByRole("heading", { + name: artworkTitle, + exact: true, + }), + ).toBeVisible(); + + // Open the artwork again and add it to the newly created gallery. + await page + .getByRole("list", { name: "Artwork results" }) + .getByRole("heading", { + name: artworkTitle, + exact: true, + }) + .click(); + + await page + .getByRole("button", { + name: "Add artwork to gallery", + }) + .click(); + + const addToGalleryDialog = page.getByRole("dialog", { + name: "Add to gallery", + }); + + await expect(addToGalleryDialog).toBeVisible(); + + await addToGalleryDialog + .getByRole("button", { + name: galleryTitle, + exact: true, + }) + .click(); + + // The modal closes after the artwork has successfully been added. + await expect(addToGalleryDialog).not.toBeVisible(); + + // Return to the catalogue and open the profile. + await page.getByRole("link", { name: "← Back" }).click(); + + // Use the desktop profile link specifically because the page contains multiple links with the same accessible name. + await page + .getByLabel("Explore Artex") + .getByRole("link", { name: "Open profile" }) + .click(); + + await expect(page).toHaveURL(/\/profile$/); + + // Open the gallery created earlier. + const createdGalleryLink = page.getByRole("link").filter({ + hasText: galleryTitle, + }); + + await expect(createdGalleryLink).toBeVisible(); + await createdGalleryLink.click(); + + await expect(page).toHaveURL(/\/galleries\/.+/); + + await expect( + page.getByRole("heading", { + name: galleryTitle, + exact: true, + level: 1, + }), + ).toBeVisible(); + + // Final assertion: the artwork added earlier is persisted in the gallery. + await expect( + page.getByRole("heading", { + name: artworkTitle, + exact: true, + level: 3, + }), + ).toBeVisible(); +}); diff --git a/e2e/gallery-management.spec.ts b/e2e/gallery-management.spec.ts new file mode 100644 index 0000000..835aee4 --- /dev/null +++ b/e2e/gallery-management.spec.ts @@ -0,0 +1,158 @@ +import { test, expect } from "@playwright/test"; + +test("manages a gallery through rename and delete", async ({ page }) => { + test.setTimeout(45_000); + + // Use unique values so repeated test runs do not conflict. + const runId = Date.now().toString().slice(-8); + const username = `gallery-e2e-${runId}`; + const password = "Playwright123!"; + const originalTitle = `Gallery ${runId}`; + const renamedTitle = `Renamed Gallery ${runId}`; + + // Register a fresh user so the test starts with an isolated account. + await page.goto("/create-account"); + + const createAccountButton = page.getByRole("button", { + name: "Create account", + exact: true, + }); + + const createAccountForm = createAccountButton.locator("xpath=ancestor::form"); + + const usernameInput = createAccountForm.getByLabel("Username"); + const passwordInput = createAccountForm.getByLabel("Password"); + + await usernameInput.fill(username); + await passwordInput.fill(password); + + await expect(usernameInput).toHaveValue(username); + await expect(passwordInput).toHaveValue(password); + + await createAccountButton.click(); + + await expect(page).toHaveURL(/\/profile$/); + + await expect( + page.getByRole("heading", { + name: "Your Gallery", + }), + ).toBeVisible(); + + // Create a new gallery. + await page.getByRole("button", { name: "Create gallery" }).click(); + + await expect( + page.getByRole("heading", { + name: "Create a new gallery", + }), + ).toBeVisible(); + + await page.getByPlaceholder("Enter gallery title").fill(originalTitle); + + await page + .getByRole("button", { + name: "Create", + exact: true, + }) + .click(); + + const galleryLink = page.getByRole("link").filter({ + hasText: originalTitle, + }); + + await expect(galleryLink).toBeVisible(); + + await galleryLink.click(); + + await expect(page).toHaveURL(/\/galleries\/.+/); + + await expect( + page.getByRole("heading", { + name: originalTitle, + exact: true, + level: 1, + }), + ).toBeVisible(); + + // Enter edit mode before renaming the gallery. + await page + .getByRole("button", { + name: "Edit gallery", + exact: true, + }) + .click(); + + await page + .getByRole("button", { + name: "Rename gallery", + }) + .click(); + + const galleryNameInput = page.getByLabel("Gallery name"); + + await expect(galleryNameInput).toHaveValue(originalTitle); + + await galleryNameInput.fill(renamedTitle); + + await page + .getByRole("button", { + name: "Save", + exact: true, + }) + .click(); + + // Verify that the renamed title is persisted and rendered. + await expect( + page.getByRole("heading", { + name: renamedTitle, + exact: true, + level: 1, + }), + ).toBeVisible(); + + await expect( + page.getByRole("status").getByText("Gallery name updated."), + ).toBeVisible(); + + // Delete the gallery while still in edit mode. + await page + .getByRole("button", { + name: "Delete gallery", + exact: true, + }) + .click(); + + const deleteDialog = page.getByRole("dialog", { + name: "Delete gallery?", + }); + + await expect(deleteDialog).toBeVisible(); + + await expect( + deleteDialog.getByText(`Delete “${renamedTitle}”? This cannot be undone.`, { + exact: false, + }), + ).toBeVisible(); + + await deleteDialog + .getByRole("button", { + name: "Delete gallery", + exact: true, + }) + .click(); + + // Successful deletion redirects back to the profile page. + await expect(page).toHaveURL(/\/profile$/); + + await expect( + page.getByRole("status").getByText("Gallery deleted."), + ).toBeVisible(); + + // The deleted gallery should no longer be listed on the profile. + await expect( + page.getByRole("link").filter({ + hasText: renamedTitle, + }), + ).toHaveCount(0); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2c683b0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "t12-project-2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "t12-project-2", + "devDependencies": { + "@playwright/test": "^1.65.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5899fea --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "t12-project-2", + "private": true, + "scripts": { + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.65.0" + } +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..91dbef1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + + use: { + baseURL: "http://localhost:5173", + trace: "on-first-retry", + }, + + webServer: [ + { + command: "MONGODB_URI=mongodb://127.0.0.1:27018 MONGODB_DB=artex_e2e_test npx tsx src/seedE2eDatabase.ts && MONGODB_URI=mongodb://127.0.0.1:27018 MONGODB_DB=artex_e2e_test npm run dev", + cwd: "./backend", + url: "http://127.0.0.1:3001", + reuseExistingServer: false, + timeout: 30_000, + }, + { + command: "npm run dev", + cwd: "./frontend", + url: "http://localhost:5173", + reuseExistingServer: true, + timeout: 30_000, + }, + ], + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); \ No newline at end of file