test: add full-stack Playwright integration test infrastructure

Sprint 12 Phase 2: Real integration tests with seed DB
- R__seed_test_data.sql (Flyway repeatable, 7 members, strains, batches, docs, board, events)
- TestResetController (profile-gated per-test DB reset)
- docker-compose.test.yml (self-contained, tmpfs Postgres)
- Dockerfile.playwright (v1.60.0, pre-installed deps)
- 13 integration spec files, 70+ test cases (@smoke + @full)
- seed-constants.ts, selectors.ts, api-client.ts test helpers
This commit is contained in:
Patrick Plate
2026-06-18 14:43:16 +02:00
parent 6e25914074
commit 776149e7d3
25 changed files with 2127 additions and 39 deletions
@@ -0,0 +1,18 @@
# IMPORTANT: Keep this version in sync with @playwright/test in package.json
FROM mcr.microsoft.com/playwright:v1.60.0-noble
WORKDIR /app
# Copy package files for dependency installation
COPY package.json pnpm-lock.yaml .npmrc ./
# Install pnpm and project dependencies at build time
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm install --frozen-lockfile
# Copy playwright config and test infrastructure
COPY playwright.config.ts tsconfig.json ./
COPY e2e/ ./e2e/
# Default command (overridden by docker-compose)
CMD ["npx", "playwright", "test", "e2e/integration/", "--reporter=list"]
+74
View File
@@ -0,0 +1,74 @@
/**
* API client for integration tests.
* Used for direct backend calls: DB verification, test reset, data assertions.
*/
const API_URL = process.env.API_URL || "http://localhost:8080"
export class ApiClient {
private token: string | null = null
async login(email: string, password: string): Promise<void> {
const res = await fetch(`${API_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
if (!res.ok) throw new Error(`Login failed: ${res.status}`)
const data = await res.json()
this.token = data.token
}
async resetDb(): Promise<void> {
const res = await fetch(`${API_URL}/api/v1/test/reset-db`, {
method: "POST",
headers: this.authHeaders(),
})
if (!res.ok) throw new Error(`DB reset failed: ${res.status}`)
}
async getMembers(): Promise<any> {
return this.get("/api/v1/members")
}
async getDocuments(): Promise<any> {
return this.get("/api/v1/documents")
}
async getBatches(): Promise<any> {
return this.get("/api/v1/batches")
}
async getDistributions(): Promise<any> {
return this.get("/api/v1/distributions")
}
async getBoardPositions(): Promise<any> {
return this.get("/api/v1/board")
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (this.token) headers["Authorization"] = `Bearer ${this.token}`
return headers
}
private async get(path: string): Promise<any> {
const res = await fetch(`${API_URL}${path}`, {
headers: this.authHeaders(),
})
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`)
return res.json()
}
private async post(path: string, body?: unknown): Promise<any> {
const res = await fetch(`${API_URL}${path}`, {
method: "POST",
headers: this.authHeaders(),
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) throw new Error(`POST ${path} failed: ${res.status}`)
return res.json()
}
}
+29 -8
View File
@@ -1,6 +1,9 @@
import { expect, test as setup } from "@playwright/test"
import path from "path"
import fs from "fs"
import path from "path"
import { expect, test as setup } from "@playwright/test"
import { SEED } from "./seed-constants"
/**
* Global setup — authenticates as admin and saves the session state
@@ -13,23 +16,41 @@ const authDir = path.join(__dirname, ".auth")
const authFile = path.join(authDir, "admin.json")
setup("authenticate as admin", async ({ page, context }) => {
const baseURL = "http://localhost:3000"
const baseURL = process.env.BASE_URL || "http://localhost:3000"
const apiUrl = process.env.API_URL || "http://localhost:8080"
// Use seed credentials (from seed-constants), overridable via env vars
const email = process.env.TEST_ADMIN_EMAIL || SEED.admin.email
const password = process.env.TEST_ADMIN_PASSWORD || SEED.admin.password
// Ensure .auth directory exists
if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true })
}
// Wait for backend health (up to 60s)
let healthy = false
for (let i = 0; i < 30; i++) {
try {
const res = await fetch(`${apiUrl}/actuator/health`)
if (res.ok) {
healthy = true
break
}
} catch {
/* retry */
}
await new Promise((r) => setTimeout(r, 2000))
}
if (!healthy) throw new Error("Backend health check failed after 60s")
// Navigate to login page
await page.goto(`${baseURL}/login`)
await page.waitForLoadState("domcontentloaded")
// Fill credentials and submit
await page.fill('input[name="email"], input[type="email"]', "admin@test.de")
await page.fill(
'input[name="password"], input[type="password"]',
"test123"
)
await page.fill('input[name="email"], input[type="email"]', email)
await page.fill('input[name="password"], input[type="password"]', password)
await page.click('button[type="submit"]')
// Wait for successful redirect away from login
@@ -0,0 +1,121 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
import { SEL } from "../selectors"
const apiClient = new ApiClient()
test.describe("Documents Page @smoke", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("displays seed documents", async ({ page }) => {
await page.goto("/documents")
await expect(page.getByText(SEED.documents.satzung.title)).toBeVisible()
await expect(page.getByText(SEED.documents.protokoll.title)).toBeVisible()
await expect(
page.getByText(SEED.documents.genehmigung.title)
).toBeVisible()
await expect(
page.getByText(SEED.documents.mietvertrag.title)
).toBeVisible()
})
test("upload button opens dialog", async ({ page }) => {
await page.goto("/documents")
const uploadBtn = page.locator(SEL.documents.uploadButton)
await expect(uploadBtn).toBeVisible()
await uploadBtn.click()
await expect(page.locator(SEL.documents.uploadDialog)).toBeVisible()
await expect(page.locator(SEL.documents.titleInput)).toBeVisible()
await expect(page.locator(SEL.documents.categorySelect)).toBeVisible()
await expect(page.locator(SEL.documents.fileInput)).toBeVisible()
})
test("upload form submits successfully", async ({ page }) => {
// Requires backend
await page.goto("/documents")
await page.locator(SEL.documents.uploadButton).click()
await expect(page.locator(SEL.documents.uploadDialog)).toBeVisible()
await page.locator(SEL.documents.titleInput).fill("Testdokument Upload")
await page.locator(SEL.documents.categorySelect).click()
await page.getByRole("option", { name: /satzung/i }).click()
// Upload a test file
const fileInput = page.locator(SEL.documents.fileInput)
await fileInput.setInputFiles({
name: "test.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("fake pdf content"),
})
const submitBtn = page.locator(SEL.documents.submitUpload)
await submitBtn.click()
// Verify success toast
await expect(page.getByText(/erfolgreich|hochgeladen/i)).toBeVisible()
})
test("download button triggers download", async ({ page }) => {
await page.goto("/documents")
const downloadBtn = page.locator(
SEL.documents.downloadButton(SEED.documents.satzung.id)
)
await expect(downloadBtn).toBeVisible()
// Verify clicking download doesn't throw an error
const downloadPromise = page.waitForEvent("download")
await downloadBtn.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBeTruthy()
})
test("delete button shows confirmation and removes document", async ({
page,
}) => {
// Requires backend
await page.goto("/documents")
const deleteBtn = page.locator(
SEL.documents.deleteButton(SEED.documents.mietvertrag.id)
)
await expect(deleteBtn).toBeVisible()
await deleteBtn.click()
// Confirmation dialog appears
await expect(page.locator(SEL.documents.deleteConfirm)).toBeVisible()
await page.locator(SEL.documents.deleteConfirm).click()
// Document removed from list
await expect(
page.getByText(SEED.documents.mietvertrag.title)
).not.toBeVisible()
})
test("category badges display correctly", async ({ page }) => {
await page.goto("/documents")
await expect(
page.locator(
SEL.documents.categoryBadge(SEED.documents.satzung.category)
)
).toBeVisible()
await expect(
page.locator(
SEL.documents.categoryBadge(SEED.documents.protokoll.category)
)
).toBeVisible()
await expect(
page.locator(
SEL.documents.categoryBadge(SEED.documents.genehmigung.category)
)
).toBeVisible()
await expect(
page.locator(
SEL.documents.categoryBadge(SEED.documents.mietvertrag.category)
)
).toBeVisible()
})
})
@@ -0,0 +1,90 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
import { SEL } from "../selectors"
const apiClient = new ApiClient()
test.describe("Board Page @smoke", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("displays seed board positions", async ({ page }) => {
await page.goto("/board")
await expect(page.getByText(SEED.board.vorsitz.title)).toBeVisible()
await expect(page.getByText(SEED.board.kasse.title)).toBeVisible()
await expect(page.getByText(SEED.board.schrift.title)).toBeVisible()
})
test("shows elected members on filled positions", async ({ page }) => {
await page.goto("/board")
await expect(page.getByText(SEED.board.vorsitz.elected)).toBeVisible()
await expect(page.getByText(SEED.board.kasse.elected)).toBeVisible()
})
test("shows vacant status for unfilled positions", async ({ page }) => {
await page.goto("/board")
const schriftCard = page.locator(
SEL.board.positionCard(SEED.board.schrift.id)
)
await expect(schriftCard).toBeVisible()
await expect(schriftCard.getByText(/vakant|unbesetzt/i)).toBeVisible()
})
test("create position opens form and submits", async ({ page }) => {
// Requires backend
await page.goto("/board")
await page.locator(SEL.board.createPositionButton).click()
// Fill form
await page.getByLabel(/titel|bezeichnung/i).fill("Beisitzer/in")
await page.getByRole("button", { name: /speichern|erstellen/i }).click()
// Verify new position appears
await expect(page.getByText("Beisitzer/in")).toBeVisible()
})
test("elect member to vacant position", async ({ page }) => {
// Requires backend
await page.goto("/board")
// Click elect on the vacant Schriftführung position
const schriftCard = page.locator(
SEL.board.positionCard(SEED.board.schrift.id)
)
await schriftCard.locator(SEL.board.electMemberButton).click()
// Select a member from dropdown/dialog
await page.getByRole("option", { name: /Lisa Bauer/i }).click()
await page.getByRole("button", { name: /speichern|wählen/i }).click()
// Verify member is now shown
await expect(page.getByText(SEED.members.lisa.name)).toBeVisible()
})
test("remove member from position shows confirmation", async ({ page }) => {
// Requires backend
await page.goto("/board")
const removeBtn = page.locator(
SEL.board.removeButton(SEED.board.vorsitz.id)
)
await removeBtn.click()
// Confirmation dialog
await expect(
page.locator(SEL.common.alertDialogConfirm)
).toBeVisible()
await page.locator(SEL.common.alertDialogConfirm).click()
// Member name no longer visible on that position
const vorsitzCard = page.locator(
SEL.board.positionCard(SEED.board.vorsitz.id)
)
await expect(
vorsitzCard.getByText(SEED.board.vorsitz.elected)
).not.toBeVisible()
})
})
@@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
import { SEL } from "../selectors"
const apiClient = new ApiClient()
test.describe("Distributions Page @smoke", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("displays recent distributions from seed", async ({ page }) => {
await page.goto("/distributions")
// Verify distributions table/list is visible
await expect(
page.locator(SEL.distributions.table).or(page.getByRole("table"))
).toBeVisible()
})
test("date filter works", async ({ page }) => {
await page.goto("/distributions")
// Look for filter buttons/tabs for today/week/month/all
const todayFilter = page.getByRole("button", { name: /heute|today/i })
const allFilter = page.getByRole("button", { name: /alle|all/i })
if (await todayFilter.isVisible()) {
await todayFilter.click()
// Page should update (no error)
await expect(page.locator("body")).toBeVisible()
}
if (await allFilter.isVisible()) {
await allFilter.click()
await expect(page.locator("body")).toBeVisible()
}
})
test("new distribution button navigates to form", async ({ page }) => {
await page.goto("/distributions")
const newBtn = page
.locator(SEL.distributions.newButton)
.or(page.getByRole("link", { name: /neue ausgabe|new/i }))
await expect(newBtn).toBeVisible()
await newBtn.click()
await page.waitForURL(/\/distributions\/new/)
})
test("shows gram total display", async ({ page }) => {
await page.goto("/distributions")
// The page should show some kind of total/summary
await expect(
page.getByText(/gramm|gesamt|total/i).first()
).toBeVisible()
})
})
@@ -0,0 +1,120 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
import { SEL } from "../selectors"
const apiClient = new ApiClient()
test.describe("Stock Page @smoke", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("displays seed batches", async ({ page }) => {
await page.goto("/stock")
await expect(
page.getByText(SEED.strains.northernLights.name)
).toBeVisible()
await expect(
page.getByText(SEED.strains.cbdCriticalMass.name)
).toBeVisible()
await expect(page.getByText(SEED.strains.amnesiaHaze.name)).toBeVisible()
await expect(page.getByText("500")).toBeVisible()
await expect(page.getByText("300")).toBeVisible()
await expect(page.getByText("200")).toBeVisible()
})
test("status filter works", async ({ page }) => {
await page.goto("/stock")
// Filter: All — should show all 3 batches
const allFilter = page.getByRole("button", { name: /alle|all/i })
if (await allFilter.isVisible()) {
await allFilter.click()
await expect(
page.getByText(SEED.strains.northernLights.name)
).toBeVisible()
await expect(
page.getByText(SEED.strains.amnesiaHaze.name)
).toBeVisible()
}
// Filter: Available — should hide recalled batch
const availableFilter = page.getByRole("button", {
name: /verfügbar|available/i,
})
if (await availableFilter.isVisible()) {
await availableFilter.click()
await expect(
page.getByText(SEED.strains.northernLights.name)
).toBeVisible()
await expect(
page.getByText(SEED.strains.amnesiaHaze.name)
).toBeHidden()
}
// Filter: Recalled — should only show recalled batch
const recalledFilter = page.getByRole("button", {
name: /zurückgerufen|recalled/i,
})
if (await recalledFilter.isVisible()) {
await recalledFilter.click()
await expect(
page.getByText(SEED.strains.amnesiaHaze.name)
).toBeVisible()
await expect(
page.getByText(SEED.strains.northernLights.name)
).toBeHidden()
}
})
test("new batch link navigates to /stock/new", async ({ page }) => {
await page.goto("/stock")
const addBtn = page
.locator(SEL.stock.addButton)
.or(page.getByRole("link", { name: /neue charge|new batch|hinzufügen/i }))
await expect(addBtn).toBeVisible()
await addBtn.click()
await page.waitForURL(/\/stock\/new/)
})
test("recall button opens AlertDialog confirmation", async ({ page }) => {
await page.goto("/stock")
const recallBtn = page.locator(
SEL.stock.recallButton(SEED.batches.northernLights.id)
)
if (await recallBtn.isVisible()) {
await recallBtn.click()
// AlertDialog should appear with confirm/cancel
await expect(
page
.locator(SEL.common.alertDialogConfirm)
.or(page.getByRole("alertdialog"))
).toBeVisible()
}
})
test("recalled batch shows RECALLED badge", async ({ page }) => {
await page.goto("/stock")
// The Amnesia Haze batch is RECALLED
const recalledRow = page.locator(
SEL.stock.row(SEED.batches.amnesiaHaze.id)
)
if (await recalledRow.isVisible()) {
await expect(
recalledRow.getByText(/recalled|zurückgerufen/i)
).toBeVisible()
} else {
// Fallback: look for the recalled badge near Amnesia Haze text
const amnesia = page.getByText(SEED.strains.amnesiaHaze.name)
await expect(amnesia).toBeVisible()
await expect(
page.getByText(/recalled|zurückgerufen/i).first()
).toBeVisible()
}
})
})
@@ -0,0 +1,128 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
const apiClient = new ApiClient()
test.describe("Calendar Page @full", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("renders current month", async ({ page }) => {
await page.goto("/calendar")
// Calendar should show current month name
const now = new Date()
const monthNames = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
]
const currentMonth = monthNames[now.getMonth()]
const currentYear = now.getFullYear().toString()
await expect(
page
.getByText(currentMonth, { exact: false })
.or(page.getByText(currentYear))
).toBeVisible()
})
test("seed events are visible", async ({ page }) => {
await page.goto("/calendar")
// There should be an upcoming assembly event (~14 days from now)
// and a past social event (~30 days ago) — look for event indicators
await expect(
page
.getByText(/versammlung|assembly/i)
.or(page.locator("[data-testid*='event']").first())
).toBeVisible()
})
test("month navigation works", async ({ page }) => {
await page.goto("/calendar")
// Find prev/next month buttons
const nextBtn = page.getByRole("button", { name: /next|vor|nächst||>/i })
const prevBtn = page.getByRole("button", {
name: /prev|zurück|vorig||</i,
})
// Navigate forward
if (await nextBtn.isVisible()) {
await nextBtn.click()
await page.waitForTimeout(300)
// Page should still render without error
await expect(page.locator("body")).toBeVisible()
}
// Navigate backward twice (back to previous month)
if (await prevBtn.isVisible()) {
await prevBtn.click()
await page.waitForTimeout(300)
await prevBtn.click()
await page.waitForTimeout(300)
await expect(page.locator("body")).toBeVisible()
}
})
test("create event opens dialog with form fields", async ({ page }) => {
await page.goto("/calendar")
const createBtn = page
.getByRole("button", { name: /erstellen|create|neues event|neu/i })
.or(page.locator('[data-testid="calendar-create-event"]'))
if (await createBtn.isVisible()) {
await createBtn.click()
// Dialog should have form fields for event creation
await expect(
page.getByRole("dialog").or(page.locator("[role='dialog']"))
).toBeVisible()
// Expect title/name field
await expect(
page
.getByLabel(/titel|name|bezeichnung/i)
.or(page.locator("input[name*='title']"))
).toBeVisible()
}
})
test("cancel event button shows confirmation", async ({ page }) => {
await page.goto("/calendar")
// Click on an existing event to open detail
const eventEl = page.locator("[data-testid*='event']").first()
if (await eventEl.isVisible()) {
await eventEl.click()
await page.waitForTimeout(300)
// Look for cancel/delete button
const cancelBtn = page.getByRole("button", {
name: /absagen|löschen|cancel|delete/i,
})
if (await cancelBtn.isVisible()) {
await cancelBtn.click()
// Should show confirmation dialog
await expect(
page.getByRole("alertdialog").or(page.getByText(/bestätigen|sicher/i))
).toBeVisible()
}
}
})
})
@@ -0,0 +1,101 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
const apiClient = new ApiClient()
test.describe("Forum Page @full", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("lists seed topics", async ({ page }) => {
await page.goto("/forum")
await expect(
page.getByText("Neue Sorten für Sommer")
).toBeVisible()
await expect(page.getByText("Bewässerungssystem")).toBeVisible()
})
test("topics show reply counts", async ({ page }) => {
await page.goto("/forum")
// Reply counts should be visible as numbers near topics
await expect(
page
.getByText(/antwort|repl/i)
.first()
.or(page.locator("[data-testid*='reply-count']").first())
).toBeVisible()
})
test("new topic button opens create form", async ({ page }) => {
await page.goto("/forum")
const newBtn = page
.getByRole("button", { name: /neues thema|new topic|erstellen/i })
.or(page.locator('[data-testid="forum-new-topic"]'))
await expect(newBtn).toBeVisible()
await newBtn.click()
// Form should appear with title + content fields
await expect(
page
.getByRole("dialog")
.or(page.locator("form"))
.or(page.getByLabel(/titel|title/i))
).toBeVisible()
})
test("create topic submits and shows new topic", async ({ page }) => {
await page.goto("/forum")
const newBtn = page
.getByRole("button", { name: /neues thema|new topic|erstellen/i })
.or(page.locator('[data-testid="forum-new-topic"]'))
await newBtn.click()
// Fill title
const titleInput = page
.getByLabel(/titel|title|thema/i)
.or(page.locator("input[name*='title']"))
await titleInput.fill("E2E Test Topic")
// Fill content
const contentInput = page
.getByLabel(/inhalt|content|nachricht|text/i)
.or(page.locator("textarea"))
await contentInput.fill("This is an integration test topic body.")
// Submit
const submitBtn = page.getByRole("button", {
name: /erstellen|submit|speichern|post/i,
})
await submitBtn.click()
// New topic should appear
await expect(page.getByText("E2E Test Topic")).toBeVisible({
timeout: 5000,
})
})
test("pin and lock buttons visible on topics", async ({ page }) => {
await page.goto("/forum")
// Admin should see pin/lock action buttons
const pinBtn = page
.getByRole("button", { name: /pin|anheften/i })
.first()
.or(page.locator("[data-testid*='pin']").first())
const lockBtn = page
.getByRole("button", { name: /lock|sperren/i })
.first()
.or(page.locator("[data-testid*='lock']").first())
// At least one should be visible for admin user
const pinVisible = await pinBtn.isVisible()
const lockVisible = await lockBtn.isVisible()
expect(pinVisible || lockVisible).toBeTruthy()
})
})
@@ -0,0 +1,122 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
const apiClient = new ApiClient()
test.describe("Info Board Page @full", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("lists seed posts with pinned post first", async ({ page }) => {
await page.goto("/info-board")
// Should have at least 2 posts visible
const posts = page.locator("[data-testid*='info-post']").or(
page.locator("article, [role='article']")
)
// Wait for content to load
await page.waitForTimeout(1000)
await expect(page.locator("body")).toBeVisible()
// Verify posts are listed (look for post content or structure)
const postElements = page
.locator("[data-testid*='post']")
.or(page.locator("article"))
const count = await postElements.count()
expect(count).toBeGreaterThanOrEqual(1)
})
test("category filter dropdown works", async ({ page }) => {
await page.goto("/info-board")
// Look for category filter
const filterSelect = page
.locator('[data-testid="info-board-category-filter"]')
.or(page.getByRole("combobox"))
.or(page.locator("select"))
if (await filterSelect.first().isVisible()) {
await filterSelect.first().click()
await page.waitForTimeout(300)
// Options should appear
await expect(page.locator("body")).toBeVisible()
}
})
test("new post dialog opens and form submits", async ({ page }) => {
await page.goto("/info-board")
const newBtn = page
.getByRole("button", { name: /neuer beitrag|new post|erstellen/i })
.or(page.locator('[data-testid="info-board-new-post"]'))
await expect(newBtn).toBeVisible()
await newBtn.click()
// Dialog should open with form
await expect(
page.getByRole("dialog").or(page.locator("[role='dialog']"))
).toBeVisible()
// Fill form fields
const titleInput = page
.getByLabel(/titel|title/i)
.or(page.locator("input[name*='title']"))
if (await titleInput.isVisible()) {
await titleInput.fill("E2E Test Beitrag")
}
const contentInput = page
.getByLabel(/inhalt|content|text/i)
.or(page.locator("textarea"))
if (await contentInput.isVisible()) {
await contentInput.fill("Test-Inhalt für Integration Test.")
}
// Submit
const submitBtn = page.getByRole("button", {
name: /erstellen|speichern|submit|posten/i,
})
if (await submitBtn.isVisible()) {
await submitBtn.click()
// Should succeed (toast or new post visible)
await page.waitForTimeout(1000)
await expect(page.locator("body")).toBeVisible()
}
})
test("pin indicator visible on pinned post", async ({ page }) => {
await page.goto("/info-board")
// Look for pin icon/badge on the first (pinned) post
await expect(
page
.locator("[data-testid*='pinned']")
.first()
.or(page.locator("[aria-label*='pin']").first())
.or(page.getByText(/📌|angepinnt|pinned/i).first())
).toBeVisible()
})
test("archive and delete buttons visible", async ({ page }) => {
await page.goto("/info-board")
// Admin should see archive/delete actions
const archiveBtn = page
.getByRole("button", { name: /archiv/i })
.first()
.or(page.locator("[data-testid*='archive']").first())
const deleteBtn = page
.getByRole("button", { name: /löschen|delete/i })
.first()
.or(page.locator("[data-testid*='delete']").first())
const archiveVisible = await archiveBtn.isVisible()
const deleteVisible = await deleteBtn.isVisible()
expect(archiveVisible || deleteVisible).toBeTruthy()
})
})
@@ -0,0 +1,76 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
const apiClient = new ApiClient()
test.describe("Grow Page @full", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("shows seed grow entries", async ({ page }) => {
await page.goto("/grow")
await expect(
page.getByText("Northern Lights Batch #2")
).toBeVisible()
await expect(page.getByText("CBD Outdoor")).toBeVisible()
})
test("displays grow stages", async ({ page }) => {
await page.goto("/grow")
// Should show VEGETATIVE and SEEDLING stage indicators
await expect(
page
.getByText(/vegetativ|vegetative/i)
.first()
.or(page.locator("[data-testid*='stage-VEGETATIVE']").first())
).toBeVisible()
await expect(
page
.getByText(/sämling|seedling/i)
.first()
.or(page.locator("[data-testid*='stage-SEEDLING']").first())
).toBeVisible()
})
test("stage progress indicators shown", async ({ page }) => {
await page.goto("/grow")
// Look for progress bars or step indicators
const progressIndicators = page
.locator("[role='progressbar']")
.or(page.locator("[data-testid*='progress']"))
.or(page.locator("[data-testid*='stage-indicator']"))
const count = await progressIndicators.count()
expect(count).toBeGreaterThanOrEqual(1)
})
test("new grow button links to correct path", async ({ page }) => {
await page.goto("/grow")
const newBtn = page
.getByRole("link", { name: /neuer grow|new grow|anlegen/i })
.or(page.locator('[data-testid="grow-new-button"]'))
.or(page.getByRole("button", { name: /neuer grow|new grow|anlegen/i }))
await expect(newBtn).toBeVisible()
await newBtn.click()
await page.waitForURL(/\/grow\/new/)
})
test("click on entry navigates to detail page", async ({ page }) => {
await page.goto("/grow")
// Click on the first grow entry
const entry = page
.getByText("Northern Lights Batch #2")
.or(page.locator("[data-testid*='grow-entry']").first())
await entry.click()
// Should navigate to /grow/[id]
await page.waitForURL(/\/grow\/[a-zA-Z0-9-]+/)
await expect(page.locator("body")).toBeVisible()
})
})
@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
const apiClient = new ApiClient()
test.describe("Compliance Dashboard @full", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("compliance dashboard loads", async ({ page }) => {
await page.goto("/compliance")
// Page should load without error
await expect(
page
.getByText(/compliance|konformität/i)
.first()
.or(page.getByRole("heading").first())
).toBeVisible()
})
test("shows area status cards", async ({ page }) => {
await page.goto("/compliance")
// Should display compliance areas: KCANG, FINANCE, DSGVO, VEREIN
await expect(page.getByText(/kcang/i)).toBeVisible()
await expect(page.getByText(/finan/i).first()).toBeVisible()
await expect(page.getByText(/dsgvo|datenschutz/i).first()).toBeVisible()
await expect(page.getByText(/verein/i).first()).toBeVisible()
})
test("overdue deadlines highlighted", async ({ page }) => {
await page.goto("/compliance")
// EÜR Abgabe should be overdue and highlighted
await expect(
page.getByText(/EÜR/i).or(page.getByText(/überfällig|overdue/i).first())
).toBeVisible()
// Overdue items should have visual distinction (red text, warning badge, etc.)
const overdueIndicator = page
.locator("[data-testid*='overdue']")
.or(page.locator(".text-destructive, .text-red, [class*='overdue']"))
.first()
if (await overdueIndicator.isVisible()) {
await expect(overdueIndicator).toBeVisible()
}
})
test("upcoming deadlines show days remaining", async ({ page }) => {
await page.goto("/compliance")
// Should display upcoming deadlines with days remaining
await expect(
page
.getByText(/tag|day/i)
.first()
.or(page.locator("[data-testid*='deadline']").first())
).toBeVisible()
})
})
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
const apiClient = new ApiClient()
test.describe("Finance Page @full", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("finance page loads", async ({ page }) => {
await page.goto("/finance")
await expect(
page
.getByRole("heading", { name: /finan/i })
.or(page.getByText(/finanzen|finance/i).first())
).toBeVisible()
})
test("sub-navigation links exist", async ({ page }) => {
await page.goto("/finance")
// Should have sub-nav links for: payments, kassenbuch, import, fee-schedules, reports
const links = [
/zahlungen|payments/i,
/kassenbuch/i,
/import/i,
/beitragsordnung|fee/i,
/berichte|reports/i,
]
for (const linkPattern of links) {
const link = page
.getByRole("link", { name: linkPattern })
.or(page.getByRole("tab", { name: linkPattern }))
.or(page.getByRole("button", { name: linkPattern }))
await expect(link.first()).toBeVisible()
}
})
test("payments sub-page loads", async ({ page }) => {
await page.goto("/finance/payments")
await expect(page.locator("body")).toBeVisible()
// Should not show an error page
await expect(page.getByText(/404|not found/i)).not.toBeVisible()
})
test("kassenbuch sub-page loads", async ({ page }) => {
await page.goto("/finance/kassenbuch")
await expect(page.locator("body")).toBeVisible()
await expect(page.getByText(/404|not found/i)).not.toBeVisible()
})
test("import sub-page loads", async ({ page }) => {
await page.goto("/finance/import")
await expect(page.locator("body")).toBeVisible()
await expect(page.getByText(/404|not found/i)).not.toBeVisible()
})
test("fee-schedules sub-page loads", async ({ page }) => {
await page.goto("/finance/fee-schedules")
await expect(page.locator("body")).toBeVisible()
await expect(page.getByText(/404|not found/i)).not.toBeVisible()
})
test("reports sub-page loads", async ({ page }) => {
await page.goto("/finance/reports")
await expect(page.locator("body")).toBeVisible()
await expect(page.getByText(/404|not found/i)).not.toBeVisible()
})
})
@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
const apiClient = new ApiClient()
test.describe("Audit Log Page @full", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
test("audit log page loads", async ({ page }) => {
await page.goto("/audit-log")
await expect(
page
.getByRole("heading", { name: /audit|protokoll/i })
.or(page.getByText(/audit/i).first())
).toBeVisible()
})
test("shows table or list structure", async ({ page }) => {
await page.goto("/audit-log")
// Should display audit entries in a table or list
const table = page
.getByRole("table")
.or(page.locator("[data-testid='audit-log-table']"))
.or(page.locator("[data-testid*='audit-entry']").first())
await expect(table.first()).toBeVisible()
})
test("has filter or search capability", async ({ page }) => {
await page.goto("/audit-log")
// Should have some kind of filter/search input
const filterInput = page
.getByRole("searchbox")
.or(page.getByPlaceholder(/such|filter|search/i))
.or(page.locator('[data-testid="audit-log-filter"]'))
.or(page.locator("input[type='search']"))
.or(page.getByRole("combobox"))
await expect(filterInput.first()).toBeVisible()
})
})
@@ -0,0 +1,295 @@
import { expect, test } from "@playwright/test"
import { ApiClient } from "../api-client"
import { SEED } from "../seed-constants"
const apiClient = new ApiClient()
test.describe("KCanG Regulatory Edge Cases @full", () => {
test.beforeEach(async () => {
await apiClient.login(SEED.admin.email, SEED.admin.password)
await apiClient.resetDb()
})
// Requires: backend quota enforcement
test("rejects adult distribution exceeding 25g/day", async ({ page }) => {
await page.goto("/distributions/new")
// Select adult member (Max Mustermann)
const memberSelect = page
.getByLabel(/mitglied|member/i)
.or(page.locator("[data-testid='distribution-member-select']"))
await memberSelect.click()
await page.getByText(SEED.members.max.name).click()
// Select strain
const strainSelect = page
.getByLabel(/sorte|strain|charge|batch/i)
.or(page.locator("[data-testid='distribution-strain-select']"))
await strainSelect.click()
await page.getByText(SEED.strains.northernLights.name).click()
// Enter 26g (exceeds 25g daily limit)
const amountInput = page
.getByLabel(/menge|amount|gramm/i)
.or(page.locator("input[name*='amount']"))
await amountInput.fill("26")
// Submit
const submitBtn = page.getByRole("button", {
name: /ausgeben|submit|speichern/i,
})
await submitBtn.click()
// Should show rejection/error
await expect(
page.getByText(/überschr|exceeded|limit|abgelehnt|rejected/i)
).toBeVisible({ timeout: 5000 })
})
// Requires: backend quota enforcement
test("accepts adult distribution of exactly 25g", async ({ page }) => {
await page.goto("/distributions/new")
const memberSelect = page
.getByLabel(/mitglied|member/i)
.or(page.locator("[data-testid='distribution-member-select']"))
await memberSelect.click()
await page.getByText(SEED.members.max.name).click()
const strainSelect = page
.getByLabel(/sorte|strain|charge|batch/i)
.or(page.locator("[data-testid='distribution-strain-select']"))
await strainSelect.click()
await page.getByText(SEED.strains.northernLights.name).click()
const amountInput = page
.getByLabel(/menge|amount|gramm/i)
.or(page.locator("input[name*='amount']"))
await amountInput.fill("25")
const submitBtn = page.getByRole("button", {
name: /ausgeben|submit|speichern/i,
})
await submitBtn.click()
// Should succeed
await expect(
page.getByText(/erfolg|success|gespeichert/i)
).toBeVisible({ timeout: 5000 })
})
// Requires: backend quota enforcement
test("rejects under-21 member with strain exceeding 10% THC", async ({
page,
}) => {
await page.goto("/distributions/new")
// Select under-21 member (Jonas Weber)
const memberSelect = page
.getByLabel(/mitglied|member/i)
.or(page.locator("[data-testid='distribution-member-select']"))
await memberSelect.click()
await page.getByText(SEED.members.jonas.name).click()
// Select Amnesia Haze (22% THC — exceeds 10% limit for under-21)
const strainSelect = page
.getByLabel(/sorte|strain|charge|batch/i)
.or(page.locator("[data-testid='distribution-strain-select']"))
await strainSelect.click()
await page.getByText(SEED.strains.amnesiaHaze.name).click()
const amountInput = page
.getByLabel(/menge|amount|gramm/i)
.or(page.locator("input[name*='amount']"))
await amountInput.fill("5")
const submitBtn = page.getByRole("button", {
name: /ausgeben|submit|speichern/i,
})
await submitBtn.click()
// Should show THC rejection
await expect(
page.getByText(/thc|überschr|exceeded|limit|abgelehnt|rejected/i)
).toBeVisible({ timeout: 5000 })
})
// Requires: backend quota enforcement
test("accepts under-21 member with strain within THC limit", async ({
page,
}) => {
await page.goto("/distributions/new")
// Select under-21 member (Jonas)
const memberSelect = page
.getByLabel(/mitglied|member/i)
.or(page.locator("[data-testid='distribution-member-select']"))
await memberSelect.click()
await page.getByText(SEED.members.jonas.name).click()
// Select CBD Critical Mass (5% THC — within 10% limit)
const strainSelect = page
.getByLabel(/sorte|strain|charge|batch/i)
.or(page.locator("[data-testid='distribution-strain-select']"))
await strainSelect.click()
await page.getByText(SEED.strains.cbdCriticalMass.name).click()
const amountInput = page
.getByLabel(/menge|amount|gramm/i)
.or(page.locator("input[name*='amount']"))
await amountInput.fill("5")
const submitBtn = page.getByRole("button", {
name: /ausgeben|submit|speichern/i,
})
await submitBtn.click()
// Should succeed
await expect(
page.getByText(/erfolg|success|gespeichert/i)
).toBeVisible({ timeout: 5000 })
})
// Requires: backend quota enforcement
test("rejects under-21 member exceeding 30g/month", async ({ page }) => {
// This test assumes Jonas has already received close to 30g this month
// The seed data should set up 31g attempted distribution
await page.goto("/distributions/new")
const memberSelect = page
.getByLabel(/mitglied|member/i)
.or(page.locator("[data-testid='distribution-member-select']"))
await memberSelect.click()
await page.getByText(SEED.members.jonas.name).click()
const strainSelect = page
.getByLabel(/sorte|strain|charge|batch/i)
.or(page.locator("[data-testid='distribution-strain-select']"))
await strainSelect.click()
await page.getByText(SEED.strains.cbdCriticalMass.name).click()
// 31g exceeds the 30g/month limit for under-21
const amountInput = page
.getByLabel(/menge|amount|gramm/i)
.or(page.locator("input[name*='amount']"))
await amountInput.fill("31")
const submitBtn = page.getByRole("button", {
name: /ausgeben|submit|speichern/i,
})
await submitBtn.click()
// Should show monthly quota rejection
await expect(
page.getByText(/überschr|exceeded|limit|monat|monthly|abgelehnt/i)
).toBeVisible({ timeout: 5000 })
})
// Requires: backend quota enforcement
test("accepts near-quota member within daily limit", async ({ page }) => {
// Thomas has 23g already this day — 2g more should be fine (25g total)
await page.goto("/distributions/new")
const memberSelect = page
.getByLabel(/mitglied|member/i)
.or(page.locator("[data-testid='distribution-member-select']"))
await memberSelect.click()
await page.getByText(SEED.members.thomas.name).click()
const strainSelect = page
.getByLabel(/sorte|strain|charge|batch/i)
.or(page.locator("[data-testid='distribution-strain-select']"))
await strainSelect.click()
await page.getByText(SEED.strains.northernLights.name).click()
const amountInput = page
.getByLabel(/menge|amount|gramm/i)
.or(page.locator("input[name*='amount']"))
await amountInput.fill("2")
const submitBtn = page.getByRole("button", {
name: /ausgeben|submit|speichern/i,
})
await submitBtn.click()
// Should succeed (23g + 2g = 25g, exactly at limit)
await expect(
page.getByText(/erfolg|success|gespeichert/i)
).toBeVisible({ timeout: 5000 })
})
// Requires: backend quota enforcement
test("rejects near-quota member exceeding daily cumulative", async ({
page,
}) => {
// Thomas has 23g already — 3g more would be 26g (exceeds 25g/day)
await page.goto("/distributions/new")
const memberSelect = page
.getByLabel(/mitglied|member/i)
.or(page.locator("[data-testid='distribution-member-select']"))
await memberSelect.click()
await page.getByText(SEED.members.thomas.name).click()
const strainSelect = page
.getByLabel(/sorte|strain|charge|batch/i)
.or(page.locator("[data-testid='distribution-strain-select']"))
await strainSelect.click()
await page.getByText(SEED.strains.northernLights.name).click()
const amountInput = page
.getByLabel(/menge|amount|gramm/i)
.or(page.locator("input[name*='amount']"))
await amountInput.fill("3")
const submitBtn = page.getByRole("button", {
name: /ausgeben|submit|speichern/i,
})
await submitBtn.click()
// Should show daily cumulative rejection
await expect(
page.getByText(/überschr|exceeded|limit|abgelehnt|rejected/i)
).toBeVisible({ timeout: 5000 })
})
// Requires: backend quota enforcement
test("shows THC warning for under-21 members on distribution page", async ({
page,
}) => {
await page.goto("/distributions/new")
// Select under-21 member (Jonas)
const memberSelect = page
.getByLabel(/mitglied|member/i)
.or(page.locator("[data-testid='distribution-member-select']"))
await memberSelect.click()
await page.getByText(SEED.members.jonas.name).click()
// Should show THC% warning/info for under-21
await expect(
page.getByText(/thc.*10|unter.*21|u21|jugendschutz/i).first()
).toBeVisible({ timeout: 3000 })
})
// Requires: backend quota enforcement
test("quota display shows correct remaining amount", async ({ page }) => {
await page.goto("/distributions/new")
// Select Thomas (near-quota member, 23g already used today)
const memberSelect = page
.getByLabel(/mitglied|member/i)
.or(page.locator("[data-testid='distribution-member-select']"))
await memberSelect.click()
await page.getByText(SEED.members.thomas.name).click()
// Should display remaining quota info
await expect(
page
.getByText(/verbleibend|remaining|rest|kontingent|quota/i)
.first()
.or(page.locator("[data-testid*='quota']").first())
).toBeVisible({ timeout: 3000 })
})
})
@@ -0,0 +1,22 @@
# Integration Tests
Full-stack integration tests that run against a real backend + database.
## Running locally
```bash
docker compose -f docker-compose.test.yml -f docker-compose.test.local.yml up --build
```
## Running in CI
```bash
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
```
## Test Structure
- Each spec file tests one page/feature
- Tests use `data-testid` selectors from `../selectors.ts`
- Expected values come from `../seed-constants.ts`
- DB is reset before each test via `ApiClient.resetDb()`
+145
View File
@@ -0,0 +1,145 @@
/**
* Deterministic seed data constants matching R__seed_test_data.sql.
* Single source of truth for all integration test assertions.
*/
export const SEED = {
club: {
id: "a0000000-0000-0000-0000-000000000001",
name: "Grüner Daumen e.V.",
},
admin: {
id: "b1000000-0000-0000-0000-000000000001",
email: "admin@gruener-daumen.de",
password: "TestAdmin123!",
},
members: {
max: {
id: "c1000000-0000-0000-0000-000000000001",
name: "Max Mustermann",
status: "ACTIVE",
},
anna: {
id: "c1000000-0000-0000-0000-000000000002",
name: "Anna Schmidt",
status: "ACTIVE",
},
jonas: {
id: "c1000000-0000-0000-0000-000000000003",
name: "Jonas Weber",
status: "ACTIVE",
isUnder21: true,
},
maria: {
id: "c1000000-0000-0000-0000-000000000004",
name: "Maria Müller",
status: "SUSPENDED",
},
thomas: {
id: "c1000000-0000-0000-0000-000000000005",
name: "Thomas Müller",
status: "ACTIVE",
nearQuota: true,
},
lisa: {
id: "c1000000-0000-0000-0000-000000000006",
name: "Lisa Bauer",
status: "ACTIVE",
},
karl: {
id: "c1000000-0000-0000-0000-000000000007",
name: "Karl Fischer",
status: "EXPELLED",
},
},
strains: {
northernLights: {
id: "d1000000-0000-0000-0000-000000000001",
name: "Northern Lights",
thc: 18.5,
cbd: 0.5,
},
cbdCriticalMass: {
id: "d1000000-0000-0000-0000-000000000002",
name: "CBD Critical Mass",
thc: 5.0,
cbd: 12.0,
},
amnesiaHaze: {
id: "d1000000-0000-0000-0000-000000000003",
name: "Amnesia Haze",
thc: 22.0,
cbd: 0.1,
},
},
batches: {
northernLights: {
id: "e1000000-0000-0000-0000-000000000001",
quantity: 500,
status: "AVAILABLE",
},
cbdCriticalMass: {
id: "e1000000-0000-0000-0000-000000000002",
quantity: 300,
status: "AVAILABLE",
},
amnesiaHaze: {
id: "e1000000-0000-0000-0000-000000000003",
quantity: 200,
status: "RECALLED",
},
},
documents: {
satzung: {
id: "f1000000-0000-0000-0000-000000000001",
title: "Vereinssatzung 2024",
category: "SATZUNG",
},
protokoll: {
id: "f1000000-0000-0000-0000-000000000002",
title: "Protokoll MV März 2024",
category: "PROTOKOLL",
},
genehmigung: {
id: "f1000000-0000-0000-0000-000000000003",
title: "KCanG-Genehmigung",
category: "GENEHMIGUNG",
},
mietvertrag: {
id: "f1000000-0000-0000-0000-000000000004",
title: "Mietvertrag",
category: "VERTRAG",
},
},
board: {
vorsitz: {
id: "g1000000-0000-0000-0000-000000000001",
title: "Vorsitzende/r",
elected: "Max Mustermann",
},
kasse: {
id: "g1000000-0000-0000-0000-000000000002",
title: "Kassenführung",
elected: "Anna Schmidt",
},
schrift: {
id: "g1000000-0000-0000-0000-000000000003",
title: "Schriftführung",
vacant: true,
},
},
counts: {
totalMembers: 7,
activeMembers: 5,
documents: 4,
batches: 3,
availableBatches: 2,
boardPositions: 3,
vacantPositions: 1,
},
kcang: {
adultDailyLimitGrams: 25,
adultMonthlyLimitGrams: 50,
under21MonthlyLimitGrams: 30,
under21MaxThcPercent: 10,
},
} as const
+72
View File
@@ -0,0 +1,72 @@
/**
* Centralized data-testid selectors for integration tests.
* Naming convention: <page>-<component>-<identifier>
*
* Note: The actual data-testid attributes will be added incrementally
* to frontend components during Phase 2E as tests are written.
*/
export const SEL = {
// Sidebar / Navigation
nav: {
sidebar: '[data-testid="nav-sidebar"]',
members: '[data-testid="nav-link-members"]',
distributions: '[data-testid="nav-link-distributions"]',
stock: '[data-testid="nav-link-stock"]',
documents: '[data-testid="nav-link-documents"]',
board: '[data-testid="nav-link-board"]',
calendar: '[data-testid="nav-link-calendar"]',
forum: '[data-testid="nav-link-forum"]',
grow: '[data-testid="nav-link-grow"]',
compliance: '[data-testid="nav-link-compliance"]',
},
// Members page
members: {
table: '[data-testid="members-table"]',
searchInput: '[data-testid="members-search-input"]',
addButton: '[data-testid="members-add-button"]',
row: (id: string) => `[data-testid="members-row-${id}"]`,
statusBadge: (id: string) => `[data-testid="members-status-${id}"]`,
},
// Documents page
documents: {
uploadButton: '[data-testid="documents-upload-button"]',
uploadDialog: '[data-testid="documents-upload-dialog"]',
titleInput: '[data-testid="documents-title-input"]',
categorySelect: '[data-testid="documents-category-select"]',
fileInput: '[data-testid="documents-file-input"]',
submitUpload: '[data-testid="documents-submit-upload"]',
downloadButton: (id: string) => `[data-testid="documents-download-${id}"]`,
deleteButton: (id: string) => `[data-testid="documents-delete-${id}"]`,
deleteConfirm: '[data-testid="documents-delete-confirm"]',
categoryBadge: (category: string) =>
`[data-testid="documents-category-${category}"]`,
row: (id: string) => `[data-testid="documents-row-${id}"]`,
},
// Board page
board: {
createPositionButton: '[data-testid="board-create-position"]',
electMemberButton: '[data-testid="board-elect-member"]',
removeButton: (id: string) => `[data-testid="board-remove-${id}"]`,
positionCard: (id: string) => `[data-testid="board-position-${id}"]`,
},
// Stock page
stock: {
addButton: '[data-testid="stock-add-button"]',
recallButton: (id: string) => `[data-testid="stock-recall-${id}"]`,
table: '[data-testid="stock-table"]',
row: (id: string) => `[data-testid="stock-row-${id}"]`,
},
// Distributions page
distributions: {
newButton: '[data-testid="distributions-new-button"]',
table: '[data-testid="distributions-table"]',
row: (id: string) => `[data-testid="distributions-row-${id}"]`,
},
// Common/shared
common: {
toast: '[data-testid="toast"]',
loadingSkeleton: '[data-testid="loading-skeleton"]',
alertDialogConfirm: '[data-testid="alert-dialog-confirm"]',
alertDialogCancel: '[data-testid="alert-dialog-cancel"]',
},
} as const
+15 -4
View File
@@ -1,6 +1,7 @@
import { defineConfig } from "@playwright/test"
import path from "path"
import { defineConfig } from "@playwright/test"
const authFile = path.join(__dirname, "e2e", ".auth", "admin.json")
export default defineConfig({
@@ -9,7 +10,7 @@ export default defineConfig({
retries: 0,
timeout: 90_000,
use: {
baseURL: "http://localhost:3000",
baseURL: process.env.BASE_URL || "http://localhost:3000",
screenshot: "on",
trace: "on-first-retry",
navigationTimeout: 60_000,
@@ -22,8 +23,7 @@ export default defineConfig({
},
{
name: "authenticated",
testMatch:
/authenticated-admin|visual-regression|accessibility/,
testMatch: /authenticated-admin|visual-regression|accessibility/,
dependencies: ["setup"],
use: {
storageState: authFile,
@@ -36,6 +36,17 @@ export default defineConfig({
/functional-flows|full-check|user-story-tests|system-test|staff-management|screenshot-tour|authenticated-tour/,
use: { browserName: "chromium" },
},
{
name: "integration",
testMatch: /integration\//,
dependencies: ["setup"],
use: {
storageState: authFile,
browserName: "chromium",
},
timeout: 90_000,
expect: { timeout: 15_000 },
},
],
outputDir: "./e2e/test-results",
})