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,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 })
})
})