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:
@@ -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"]
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user