chore: seminarhof customer project

This commit is contained in:
e2e
2026-06-21 23:19:18 +02:00
commit dca4c76a13
161 changed files with 18517 additions and 0 deletions

7
e2e/tests/cucumber.mjs Normal file
View File

@@ -0,0 +1,7 @@
export default {
requireModule: ['tsx'],
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
paths: ['tests/features/**/*.feature'],
format: ['progress', 'html:tests/reports/cucumber-report.html'],
forceExit: true,
}

View File

View File

@@ -0,0 +1,102 @@
@admin @bookings
Feature: Admin booking detail — read & edit
The admin booking-detail page replaces the spreadsheet admins must be able
to *edit* every column they used to edit, not just look at it.
Background:
Given I am logged in as "christina@seminarhof.example" with password "admin1234"
When I visit "/admin/bookings/b_yoga_2026_summer"
# ── Read ────────────────────────────────────────────────────────────────
Scenario: admin opens the summer retreat detail
Then I see "Summer Yoga Retreat 2026"
And I see "Yoga Retreat e.V."
And I see "Teilnehmende"
Scenario: admin sees the seeded participant list
When I click "Teilnehmende"
Then I see "Anja Weber"
And I see "Bernd Krause"
# ── Edit booking metadata ───────────────────────────────────────────────
Scenario: admin edits the course name and persists across reload
When I fill "courseName" with "Summer Yoga Retreat 2026 revised"
And I click "Änderungen speichern"
Then I see "Gespeichert"
When I visit "/admin/bookings/b_yoga_2026_summer"
Then I see "Summer Yoga Retreat 2026 revised"
Scenario: admin updates expected participants
When I fill "expectedPersons" with "32"
And I click "Änderungen speichern"
Then I see "Gespeichert"
Scenario: admin toggles half-house and online listing flags
When I select "Ja" from "halfHouse"
And I select "Ja" from "onlineAd"
And I click "Änderungen speichern"
Then I see "Gespeichert"
Scenario: admin cannot save when no field changed
Then I see "Änderungen speichern"
# Save button is disabled until a field is touched — clicking has no effect.
# ── Edit organisation ──────────────────────────────────────────────────
Scenario: admin edits organisation contact email and phone
When I fill "organisation-email" with "ops@yoga-retreat.example"
And I fill "organisation-phone" with "+49 30 12345678"
And I click "Organisation speichern"
Then I see "Organisation gespeichert"
When I visit "/admin/bookings/b_yoga_2026_summer"
Then the field "organisation-email" has value "ops@yoga-retreat.example"
And the field "organisation-phone" has value "+49 30 12345678"
Scenario: organisation save rejects an invalid email
When I fill "organisation-email" with "not-an-email"
And I click "Organisation speichern"
Then I do not see "Organisation gespeichert"
# ── Booking status transitions ─────────────────────────────────────────
Scenario: admin cancels a booking
When I accept the next confirmation
And I click "Buchung stornieren"
Then I see "Storniert"
And I do not see "Buchung stornieren"
# ── Participants CRUD ──────────────────────────────────────────────────
Scenario: admin adds a participant
When I click "Teilnehmende"
And I fill "new-participant-name" with "Ingrid Vogel"
And I click "Hinzufügen"
Then I see "Ingrid Vogel"
Scenario: admin removes a participant
When I click "Teilnehmende"
And I accept the next confirmation
And I click "remove-participant-p_s_08"
Then I do not see "Hans Becker"
Scenario: admin changes a participant's diet
When I click "Teilnehmende"
And I select "Pescetarisch" from "participant-diet-p_s_06"
Then the field "participant-diet-p_s_06" has value "Pescetarisch"
# ── Allergies CRUD ─────────────────────────────────────────────────────
Scenario: admin adds an allergy to a participant
When I click "Teilnehmende"
And I fill "new-allergy-p_s_02" with "shellfish"
And I click "add-allergy-p_s_02"
Then I see "Meeresfrüchte"
Scenario: admin removes an existing allergy
When I click "Teilnehmende"
And I click "remove-allergy-p_s_01-nuts"
Then I do not see "nuts"
# ── Rooms assign / unassign ────────────────────────────────────────────
Scenario: admin assigns and then unassigns a participant to a room
When I click "Zimmer"
And I select "Anja Weber" from "assign-room-r_1"
Then I see "Anja Weber"
When I click "Entfernen"
Then the field "assign-room-r_1" has value ""

View File

@@ -0,0 +1,41 @@
@auth
Feature: Authentication
Users authenticate with email + password. Sessions are cookie-based.
Roles (client / admin / owner) gate access to portals.
Background:
Given I visit "/login"
Scenario: client logs in and lands on their portal
When I log in as "demo@yoga-retreat.example" with password "demo1234"
Then the URL contains "/"
And I see "Seminarhof Drawehn"
Scenario: admin logs in and reaches the admin bookings list
When I log in as "christina@seminarhof.example" with password "admin1234"
And I visit "/admin/bookings"
Then I see "Buchungen"
And I see "Summer Yoga Retreat"
Scenario: owner logs in and reaches the admin bookings list
When I log in as "sarah@seminarhof.example" with password "owner1234"
And I visit "/admin/bookings"
Then I see "Buchungen"
Scenario: wrong password is rejected
When I fill "input[type=email]" with "demo@yoga-retreat.example"
And I fill "input[type=password]" with "wrong-password"
And I click "Anmelden"
Then I see "E-Mail oder Passwort ist falsch."
Scenario: client is redirected away from the admin portal
When I log in as "demo@yoga-retreat.example" with password "demo1234"
And I visit "/admin/bookings"
Then the URL does not contain "/admin/"
Scenario: logout clears the session
Given I am logged in as "demo@yoga-retreat.example" with password "demo1234"
When I log out
Then I see "Sie wurden abgemeldet. Bis bald."
When I click "Zurück zur Anmeldung"
Then the URL contains "/login"

View File

@@ -0,0 +1,8 @@
@public @availability
Feature: Public availability calendar
Visitors can see which days the venue is free to book.
Scenario: visitor opens the availability calendar
Given I visit "/availability"
Then I see "Seminarhof Drawehn"
And I see "2026"

View File

@@ -0,0 +1,42 @@
@booking @flow
Feature: Full booking flow — enquiry through confirmed
A client submits an enquiry via the public form. The admin then drives
the booking through every step: approval, contract, deposit, confirmation.
# Guest: submit enquiry
Scenario: client submits an enquiry via the public form
Given I visit "/enquiry"
When I fill "courseName" with "E2E Full Flow Test"
And I fill "startDate" with "2028-06-15"
And I fill "endDate" with "2028-06-22"
And I fill "organisationName" with "E2E Organisation"
And I fill "contactEmail" with "e2e@example.com"
And I check "agbAccepted"
And I check "privacyAccepted"
And I click "Anfrage absenden"
Then I see "Ihre Anfrage ist eingegangen"
# ── Admin: full flow from enquiry to confirmed ──────────────────────────
Scenario: admin drives a booking from enquiry through to confirmed
Given I am logged in as "christina@seminarhof.example" with password "admin1234"
# 1. Open the seeded enquiry directly
When I visit "/admin/bookings/b_lc_enquiry"
# 2. Approve the enquiry → reserved
When I trigger booking action "action-approve-enquiry"
Then I see "Reserviert"
And the audit log tab includes "Reservierung bestätigt"
# 3. Send the contract manually (bypasses the 365-day cron window)
When I trigger booking action "action-send-contract"
Then the audit log tab includes "Vertrag & Anzahlungsrechnung"
# 4. Record the client's contract signature
When I trigger booking action "action-contract-approved"
Then I see "Anzahlung als erhalten markieren"
# 5. Record the deposit received → confirmed
When I trigger booking action "action-deposit-received"
Then I see "Bestätigt"
And the audit log tab includes "Buchung bestätigt"

View File

@@ -0,0 +1,25 @@
@admin @lifecycle
Feature: Booking lifecycle — rule-based cron assertions
Verifies each cron rule fires against pre-seeded bookings whose dates are
computed relative to today in testReset, so the rules always trigger.
Background:
Given I am logged in as "christina@seminarhof.example" with password "admin1234"
# ── R1: contract + deposit email ────────────────────────────────────────
Scenario: lifecycle R1 — cron sends contract and deposit email to a reserved booking
# b_lc_reserved: reserved, no contractSentAt, startDate 200 days away
Then the lifecycle cron sent 1 contracts
And the audit log for "b_lc_reserved" includes "Vertrag & Anzahlungsrechnung"
# ── R2: deposit reminder ─────────────────────────────────────────────────
Scenario: lifecycle R2 — cron sends deposit reminder when deposit is 7+ days unpaid
# b_lc_contract_sent: contractSentAt 8 days ago, deposit invoice unpaid
Then the lifecycle cron sent 1 reminders
And the audit log for "b_lc_contract_sent" includes "Anzahlungserinnerung"
# ── R4: auto-complete ────────────────────────────────────────────────────
Scenario: lifecycle R4 — cron auto-completes a confirmed booking after it ends
# b_lc_confirmed: confirmed, endDate yesterday
Then the lifecycle cron completed 2 bookings
And the booking "b_lc_confirmed" has status "Beendet"

View File

@@ -0,0 +1,13 @@
@public @events
Feature: Public events listing
Anyone can browse the public retreats listing without logging in. Only
bookings flagged for online listing appear here.
Scenario: visitor sees the published summer retreat
Given I visit "/events"
Then I see "Summer Yoga Retreat 2026"
And I see "Yoga Retreat e.V."
Scenario: unpublished retreats stay hidden
Given I visit "/events"
Then I do not see "Winter Stille 2026"

View File

@@ -0,0 +1,30 @@
@admin @invoices
Feature: Invoice creation and payment recording
Admin issues invoices against a booking and records payments when they
arrive. Both actions create audit trail entries.
Background:
Given I am logged in as "christina@seminarhof.example" with password "admin1234"
When I visit "/admin/bookings/b_yoga_2026_summer"
And I click "Rechnungen"
Scenario: admin creates a final invoice
When I create an invoice numbered "2026-FIN-002" for 1500.00
Then I see "2026-FIN-002"
And I see "Offen"
Scenario: admin creates an invoice with an attached PDF
When I attach invoice PDF "invoice-sample.pdf"
And I create an invoice numbered "2026-PDF-001" for 500.00
Then I see "PDF öffnen"
Scenario: admin records a payment on an outstanding invoice
When I create an invoice numbered "2026-PAY-001" for 250.00
And I mark invoice "2026-PAY-001" as paid on "2026-05-10"
Then I see "Bezahlt"
Scenario: admin cancels an outstanding invoice
When I create an invoice numbered "2026-CXL-001" for 100.00
And I accept the next confirmation
And I click "invoice-cancel-2026-CXL-001"
Then I see "Storniert"

36
e2e/tests/fixtures/invoice-sample.pdf vendored Normal file
View File

@@ -0,0 +1,36 @@
%PDF-1.1
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT
/F1 18 Tf
36 120 Td
(Invoice Sample) Tj
ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000010 00000 n
0000000063 00000 n
0000000122 00000 n
0000000248 00000 n
0000000342 00000 n
trailer
<< /Root 1 0 R /Size 6 >>
startxref
412
%%EOF

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,270 @@
import { Given, When, Then } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import type { AppWorld } from '../support/world.js'
/**
* Generic step library — shared across every project that copies this harness.
*
* Project-specific business steps (e.g. "I create a booking for <date>")
* belong in their own *.steps.ts files. Keep this file framework-agnostic.
*/
Given('I visit {string}', async function (this: AppWorld, path: string) {
await this.gotoApp(path)
})
Given(
'I am logged in as {string} with password {string}',
async function (this: AppWorld, email: string, password: string) {
await this.login(email, password)
},
)
When('I log in as {string} with password {string}', async function (
this: AppWorld,
email: string,
password: string,
) {
await this.login(email, password)
})
When('I log out', async function (this: AppWorld) {
await this.logout()
})
/**
* Resolve a "field" by trying multiple strategies. CSS selectors win when the
* name looks like one. Otherwise: data-testid, then accessible label, then
* placeholder. Mantine sometimes parks data-testid on the wrapper div, so we
* descend to the inner control when present.
*/
const looksLikeCss = (s: string) => /[\[#.>:\s]/.test(s) || s.startsWith('input')
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
async function field(world: AppWorld, name: string) {
if (looksLikeCss(name)) return world.page.locator(name).first()
// Wait for the testid to attach — page often paints before React Query
// resolves, so a synchronous count() check fires too early.
const byId = world.page.locator(`[data-testid="${name}"]`).first()
try {
await byId.waitFor({ state: 'attached', timeout: 5000 })
const inner = byId.locator('input, textarea, select').first()
if (await inner.count()) return inner
return byId
} catch {
// Fall through to label / placeholder.
}
const byLabel = world.page.getByLabel(name, { exact: false })
if (await byLabel.count()) return byLabel.first()
return world.page.getByPlaceholder(name, { exact: false }).first()
}
async function isVisibleWithin(
target: ReturnType<AppWorld['page']['locator']>,
timeout = 5000,
) {
try {
await target.waitFor({ state: 'visible', timeout })
return true
} catch {
return false
}
}
async function activate(target: ReturnType<AppWorld['page']['locator']>, timeout = 5000) {
try {
await target.click({ timeout })
return
} catch {
await target.evaluate((node) => {
;(node as HTMLElement).click()
})
}
}
async function settleAfterClick(world: AppWorld, previousUrl?: string) {
if (previousUrl) {
await world.page
.waitForURL((url) => url.toString() !== previousUrl, { timeout: 3000 })
.catch(() => {})
}
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await world.page.waitForTimeout(750)
}
async function clickAndConfirmIfNeeded(world: AppWorld, target: ReturnType<AppWorld['page']['locator']>) {
await activate(target)
await settleAfterClick(world)
if (
await isVisibleWithin(
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
1000,
)
) {
await activate(
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
1000,
)
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await world.page.waitForTimeout(250)
return
}
if (
await isVisibleWithin(
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
1000,
)
) {
await activate(
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
1000,
)
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await world.page.waitForTimeout(250)
}
}
When(
'I fill {string} with {string}',
async function (this: AppWorld, name: string, value: string) {
const target = await field(this, name)
await target.fill('')
await target.fill(value)
},
)
When('I check {string}', async function (this: AppWorld, name: string) {
const target = await field(this, name)
await target.check()
})
When('I uncheck {string}', async function (this: AppWorld, name: string) {
const target = await field(this, name)
await target.uncheck()
})
When(
'I select {string} from {string}',
async function (this: AppWorld, value: string, name: string) {
console.log(`[select] resolving "${name}"`)
const target = await field(this, name)
const tag = await target
.evaluate((el) => (el as HTMLElement).tagName.toLowerCase())
.catch(() => '')
console.log(`[select] resolved → ${tag}`)
if (tag === 'select') {
await target.selectOption({ label: value })
return
}
console.log(`[select] clicking to open`)
await target.click()
console.log(`[select] clicking option "${value}"`)
await this.page
.getByRole('option', { name: value, exact: false })
.first()
.click()
console.log(`[select] done`)
},
)
Then(
'in row {string} I do not see {string}',
async function (this: AppWorld, rowText: string, text: string) {
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
await expect(row.getByText(text, { exact: false }).first()).toBeHidden()
},
)
When(
'in row {string} I click {string}',
async function (this: AppWorld, rowText: string, label: string) {
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
const roles = ['button', 'link', 'menuitem'] as const
const previousUrl = this.page.url()
for (const role of roles) {
const candidate = row.getByRole(role, { name: new RegExp(label, 'i') }).first()
if (await isVisibleWithin(candidate)) {
await activate(candidate)
await settleAfterClick(this, previousUrl)
return
}
}
const byText = row.getByText(label, { exact: false }).first()
if (await isVisibleWithin(byText)) {
await activate(byText)
await settleAfterClick(this, previousUrl)
return
}
throw new Error(`Could not click "${label}" inside row "${rowText}"`)
},
)
When('I click {string}', async function (this: AppWorld, label: string) {
// 1) data-testid first — feature files use this for testid'd row actions.
const byId = this.page.getByTestId(label).first()
if (await isVisibleWithin(byId)) {
await clickAndConfirmIfNeeded(this, byId)
return
}
// 2) Common interactive roles. Mantine renders tabs with role=tab,
// anchors and buttons-as-links with role=link, etc.
const fuzzyName = new RegExp(escapeRegex(label), 'i')
const roles = ['button', 'tab', 'link', 'menuitem'] as const
for (const role of roles) {
const candidate = this.page.getByRole(role, { name: fuzzyName }).first()
if (await isVisibleWithin(candidate)) {
await clickAndConfirmIfNeeded(this, candidate)
return
}
}
// 3) Plain text fallback for click-handler-on-div widgets.
const byText = this.page.getByText(label, { exact: false }).first()
if (await isVisibleWithin(byText)) {
await clickAndConfirmIfNeeded(this, byText)
return
}
throw new Error(`Could not click "${label}"`)
})
When('I accept the next confirmation', async function (this: AppWorld) {
this.page.once('dialog', (d) => void d.accept())
})
When(
'I answer the next prompt with {string}',
async function (this: AppWorld, value: string) {
await this.page.evaluate((v) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).prompt = () => v
}, value)
},
)
Then('I see {string}', async function (this: AppWorld, text: string) {
await this.expectText(text)
})
Then(
'the field {string} has value {string}',
async function (this: AppWorld, name: string, value: string) {
const target = await field(this, name)
await expect(target).toHaveValue(value)
},
)
Then('I do not see {string}', async function (this: AppWorld, text: string) {
await expect(
this.page.getByText(text, { exact: false }).first(),
).toBeHidden()
})
Then('the URL contains {string}', async function (this: AppWorld, fragment: string) {
await this.page.waitForURL((url) => url.toString().includes(fragment))
})
Then(
'the URL does not contain {string}',
async function (this: AppWorld, fragment: string) {
await this.page.waitForURL((url) => !url.toString().includes(fragment))
},
)

View File

@@ -0,0 +1,61 @@
import { When } from '@cucumber/cucumber'
import path from 'node:path'
import type { AppWorld } from '../support/world.js'
/**
* Project-specific invoice steps. Talk to the Invoices tab on the admin
* booking-detail page (Mantine form). Locale-aware: the form uses German
* labels by default in this app.
*/
When(
'I create an invoice numbered {string} for {float} €',
async function (this: AppWorld, invoiceNumber: string, amountEuro: number) {
// The form sits in the "Neue Rechnung" panel — use data-testid hooks so we
// are not coupled to localized labels.
const numberField = this.page
.getByTestId('invoice-create-form')
.locator('input[data-testid="invoice-number"], [data-testid="invoice-number"] input')
.first()
await numberField.fill(invoiceNumber)
const amount = this.page
.getByTestId('invoice-create-form')
.locator('input[data-testid="invoice-amount"], [data-testid="invoice-amount"] input')
.first()
await amount.fill('')
await amount.fill(String(amountEuro.toFixed(2)))
await this.page
.getByRole('button', { name: /rechnung erstellen|create invoice/i })
.click()
// Wait for the row to appear in the invoices table above the form.
await this.page.getByText(invoiceNumber, { exact: false }).first().waitFor({
state: 'visible',
})
},
)
When(
'I attach invoice PDF {string}',
async function (this: AppWorld, filename: string) {
const filePath = path.resolve(process.cwd(), 'tests', 'fixtures', filename)
await this.page.getByTestId('invoice-pdf').setInputFiles(filePath)
},
)
When(
'I mark invoice {string} as paid on {string}',
async function (this: AppWorld, invoiceNumber: string, paidOn: string) {
// Stub the prompt before clicking — the UI uses window.prompt to collect
// the payment date.
await this.page.evaluate((value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).prompt = () => value
}, paidOn)
const row = this.page.getByRole('row', { name: new RegExp(invoiceNumber) })
await row
.getByTestId(`invoice-mark-paid-${invoiceNumber}`)
.click()
await row.getByText(/bezahlt|paid/i).waitFor({ state: 'visible' })
},
)

View File

@@ -0,0 +1,104 @@
import { When, Then } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import type { AppWorld } from '../support/world.js'
import { config } from '../support/types.js'
When('the lifecycle cron runs', async function (this: AppWorld) {
await this.runLifecycle()
})
Then(
'the lifecycle cron sent {int} contracts',
async function (this: AppWorld, count: number) {
const result = await this.runLifecycle()
expect(result.contractsSent).toBe(count)
},
)
Then(
'the lifecycle cron sent {int} reminders',
async function (this: AppWorld, count: number) {
const result = await this.runLifecycle()
expect(result.reminders).toBe(count)
},
)
Then(
'the lifecycle cron completed {int} bookings',
async function (this: AppWorld, count: number) {
const result = await this.runLifecycle()
expect(result.ended).toBe(count)
},
)
When(
'I search the bookings list for {string}',
async function (this: AppWorld, text: string) {
await this.page
.getByPlaceholder(/suchen/i)
.first()
.fill(text)
await this.page.waitForTimeout(400) // debounce
},
)
When(
'I trigger booking action {string}',
async function (this: AppWorld, actionId: string) {
const action = this.page.getByTestId(actionId).first()
await action.waitFor({ state: 'visible', timeout: config.responseTimeout })
await action.click({ timeout: config.responseTimeout })
const confirm = this.page.getByTestId(`${actionId}-confirm`).first()
await confirm.waitFor({ state: 'visible', timeout: config.responseTimeout })
await confirm.click({ timeout: config.responseTimeout })
await this.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await this.page.waitForTimeout(750)
},
)
Then(
'the audit log tab includes {string}',
async function (this: AppWorld, entry: string) {
await this.page.getByRole('tab', { name: /protokoll|audit/i }).click()
await expect(
this.page.getByText(entry, { exact: false }).first(),
).toBeVisible({ timeout: config.responseTimeout })
},
)
When(
'the admin approves the booking {string}',
async function (this: AppWorld, bookingId: string) {
await this.gotoApp(`/admin/bookings/${bookingId}`)
await this.page.getByRole('button', { name: /reserv/i }).first().click()
await this.page.waitForTimeout(500)
},
)
When(
'the admin records the deposit received for {string}',
async function (this: AppWorld, bookingId: string) {
await this.gotoApp(`/admin/bookings/${bookingId}`)
await this.page.getByRole('button', { name: /anzahlung/i }).first().click()
await this.page.waitForTimeout(500)
},
)
Then(
'the booking {string} has status {string}',
async function (this: AppWorld, bookingId: string, status: string) {
await this.gotoApp(`/admin/bookings/${bookingId}`)
await this.expectText(status)
},
)
Then(
'the audit log for {string} includes {string}',
async function (this: AppWorld, bookingId: string, entry: string) {
await this.gotoApp(`/admin/bookings/${bookingId}`)
await this.page.getByRole('tab', { name: /protokoll|audit/i }).click()
await expect(
this.page.getByText(entry, { exact: false }).first(),
).toBeVisible({ timeout: config.responseTimeout })
},
)

254
e2e/tests/support/hooks.ts Normal file
View File

@@ -0,0 +1,254 @@
import {
Before,
After,
BeforeAll,
AfterAll,
setDefaultTimeout,
} from '@cucumber/cucumber'
import { cp, mkdir, rm } from 'fs/promises'
import { spawn, execFileSync, type ChildProcess } from 'child_process'
import { resolve, dirname } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { createRequire } from 'module'
// @pikku/cli locks its `exports` to `.` only, so these db test-helpers aren't
// importable by bare specifier. Resolve the package's real dist dir via the `.`
// export and dynamic-import the file by absolute path — the exports map only
// governs bare specifiers, not absolute paths. Done lazily (not top-level)
// because cucumber transpiles this module to CJS, which bans top-level await.
// Stopgap; needs a public @pikku/cli test-helper export upstream.
async function loadDbHelpers() {
const cliBinDir = dirname(createRequire(import.meta.url).resolve('@pikku/cli'))
return import(pathToFileURL(resolve(cliBinDir, '../src/functions/db/local-db.js')).href)
}
// Seed inline via node:sqlite rather than the CLI's `seed()`: under cucumber's
// registered ESM loader, the CLI's memoized sqlite runtime opens the migrated
// file but reads an empty schema (no such table: venue). A fresh DatabaseSync
// against the same file sees the tables correctly, so do the seed ourselves.
async function seedGoldenDb(dbFile: string, seedFile: string) {
const { readFileSync, existsSync } = await import('fs')
if (!existsSync(seedFile)) return
const { DatabaseSync } = await (new Function('return import("node:sqlite")')() as Promise<{
DatabaseSync: new (f: string) => { exec(sql: string): void; close(): void }
}>)
const db = new DatabaseSync(dbFile)
db.exec('BEGIN')
try {
db.exec(readFileSync(seedFile, 'utf8'))
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
throw err
} finally {
db.close()
}
}
import type { AppWorld } from './world.js'
import { config } from './types.js'
setDefaultTimeout(config.responseTimeout)
let backendProcess: ChildProcess | undefined
let frontendProcess: ChildProcess | undefined
let goldenDbPath: string | undefined
let scenarioDbPath: string | undefined
async function waitForUrl(url: string, label: string, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const res = await fetch(url)
if (res.status < 500) return
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500))
}
throw new Error(`[e2e] ${label} did not become ready at ${url} within ${timeoutMs}ms`)
}
function startProcess({
cmd,
cwd,
env,
label,
detached = true,
}: {
cmd: string
cwd: string
env: NodeJS.ProcessEnv
label: string
detached?: boolean
}) {
const p = spawn(cmd, {
cwd,
env,
stdio: 'pipe',
shell: true,
detached,
})
p.stderr?.on('data', (d: Buffer) => process.stderr.write(`[${label}] ${d}`))
p.stdout?.on('data', (d: Buffer) => process.stdout.write(`[${label}] ${d}`))
return p
}
async function stopProcess(p: ChildProcess | undefined) {
if (!p?.pid) return
try {
process.kill(-p.pid, 'SIGTERM')
} catch {
try {
p.kill('SIGTERM')
} catch {
// ignore
}
}
await new Promise((r) => setTimeout(r, 1000))
}
function killPort(port: number) {
try {
const out = execFileSync('lsof', ['-ti', `tcp:${port}`], {
encoding: 'utf8',
}).trim()
if (!out) return
for (const pid of out.split(/\s+/)) {
if (!pid) continue
try {
process.kill(Number(pid), 'SIGTERM')
} catch {
// ignore
}
}
} catch {
// no listeners / lsof unavailable
}
}
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '../../..')
const runtimeDir = resolve(repoRoot, '.e2e-runtime')
const functionsOutDir = resolve(repoRoot, 'packages/functions/.pikku')
const defaultFrontendCmd = 'yarn dev'
async function buildGoldenDb() {
const { resolveLocalDb, reset, migrateAndCodegen } = await loadDbHelpers()
await mkdir(runtimeDir, { recursive: true })
goldenDbPath = resolve(runtimeDir, 'golden.db')
await rm(goldenDbPath, { force: true })
const resolved = resolveLocalDb(goldenDbPath, repoRoot, functionsOutDir, runtimeDir)
if (!resolved) {
throw new Error('[e2e] failed to resolve local DB config')
}
reset(resolved, repoRoot)
await migrateAndCodegen(resolved)
await seedGoldenDb(resolved.dbFile, resolved.seedFile)
}
async function startScenarioBackend(scenarioId: string) {
if (!goldenDbPath) {
throw new Error('[e2e] golden DB missing')
}
await stopProcess(backendProcess)
killPort(5003)
scenarioDbPath = resolve(runtimeDir, `${scenarioId}.db`)
await rm(scenarioDbPath, { force: true })
await cp(goldenDbPath, scenarioDbPath)
backendProcess = startProcess({
cmd: 'yarn dev:backend',
cwd: repoRoot,
env: {
...process.env,
NODE_ENV: 'test',
PIKKU_DEV_DB_FILE: scenarioDbPath,
DATABASE_URL: `file:${scenarioDbPath}`,
},
label: 'backend',
})
await waitForUrl(config.apiUrl, 'backend')
}
BeforeAll(async function () {
process.env.API_URL = process.env.API_URL ?? 'http://localhost:5003'
process.env.APP_URL = process.env.APP_URL ?? 'http://localhost:5001'
if (config.isolatedDb) {
await buildGoldenDb()
}
if (!config.manageServers) {
// Caller is expected to start backend + frontend (e.g. via pm2, docker,
// or a separate `yarn dev` terminal) before running tests.
if (config.isolatedDb) {
throw new Error('[e2e] E2E_ISOLATED_DB=1 requires E2E_MANAGE_SERVERS=1')
}
return
}
// Project-specific: override these commands by setting E2E_BACKEND_CMD /
// E2E_FRONTEND_CMD env vars. The defaults assume `yarn dev` at the repo
// root spawns the frontend, and the project provides a `dev:backend`
// script for the API.
const frontendCmd = process.env.E2E_FRONTEND_CMD ?? defaultFrontendCmd
killPort(5001)
frontendProcess = startProcess({
cmd: frontendCmd,
cwd: repoRoot,
env: {
...process.env,
NODE_ENV: 'test',
VITE_API_URL: process.env.API_URL,
},
label: 'frontend',
})
await waitForUrl(config.appUrl, 'frontend')
})
AfterAll(async function () {
await stopProcess(backendProcess)
await stopProcess(frontendProcess)
if (scenarioDbPath) await rm(scenarioDbPath, { force: true }).catch(() => {})
if (goldenDbPath) await rm(goldenDbPath, { force: true }).catch(() => {})
})
Before(async function (this: AppWorld, { pickle }) {
if (config.isolatedDb) {
const scenarioId = pickle.id.replace(/[^a-zA-Z0-9_-]/g, '_')
await startScenarioBackend(`scenario-${scenarioId}`)
}
await this.openBrowser()
if (!config.isolatedDb && config.resetUrl) {
const body = config.resetRpcName
? JSON.stringify({ rpcName: config.resetRpcName, data: {} })
: undefined
await fetch(config.resetUrl, {
method: 'POST',
headers: body ? { 'content-type': 'application/json' } : undefined,
body,
})
.then((res) => {
if (!res.ok) {
process.stderr.write(
`[e2e] WARN: reset hook ${config.resetUrl} returned ${res.status}\n`,
)
}
})
.catch(() => {
process.stderr.write(`[e2e] WARN: reset hook ${config.resetUrl} unreachable\n`)
})
}
})
After(async function (this: AppWorld) {
await this.closeBrowser()
if (config.isolatedDb) {
await stopProcess(backendProcess)
backendProcess = undefined
if (scenarioDbPath) {
await rm(scenarioDbPath, { force: true }).catch(() => {})
scenarioDbPath = undefined
}
}
})

View File

@@ -0,0 +1,44 @@
// Project-level e2e configuration. Override via env vars per environment.
//
// Local defaults:
// apps/app → vite on 5001
// backend → Pikku API on 5003
export const config = {
/** URL of the running frontend (vite dev server or built preview). */
get appUrl() {
return process.env.APP_URL ?? 'http://localhost:5001'
},
/** URL of the running backend (Pikku API / CFW worker). */
get apiUrl() {
return process.env.API_URL ?? 'http://localhost:5003'
},
/** Per-step Playwright timeout (ms). */
get responseTimeout() {
return Number(process.env.E2E_TIMEOUT ?? 30_000)
},
/** Whether the harness should spawn the dev servers itself. */
get manageServers() {
return process.env.E2E_MANAGE_SERVERS === '1'
},
/** Whether the harness should isolate each scenario with a copied golden DB. */
get isolatedDb() {
return process.env.E2E_ISOLATED_DB === '1'
},
/**
* Reset hook URL — POST here to reset the DB to seed state between scenarios.
* Project must implement this as a sessionless RPC gated behind a test-only
* flag (e.g. NODE_ENV !== 'production'). Leave undefined to skip reset.
*/
get resetUrl() {
return process.env.E2E_RESET_URL
},
/**
* RPC name to invoke at resetUrl. When set, the reset hook posts
* `{rpcName, data: {}}` as the body — matching pikku's public /rpc/:name
* envelope. Leave unset for non-pikku reset endpoints.
*/
get resetRpcName() {
return process.env.E2E_RESET_RPC_NAME
},
} as const

128
e2e/tests/support/world.ts Normal file
View File

@@ -0,0 +1,128 @@
import { World, setWorldConstructor } from '@cucumber/cucumber'
import {
chromium,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test'
import { config } from './types.js'
/**
* AppWorld — generic Playwright world for portal-style apps.
*
* Exposes the primitives every feature needs:
* - openBrowser / closeBrowser — lifecycle
* - gotoApp(path) — navigate within the frontend
* - login(email, password) — fill + submit /login, wait for redirect
* - logout() — visit /logout
* - expectText(text) — assert visible text
*
* Project-specific helpers (e.g. createBooking, assignRoom) belong in
* step files, not here. Keep this generic.
*/
export class AppWorld extends World {
browser!: Browser
context!: BrowserContext
page!: Page
async openBrowser() {
const headed = process.env.HEADED === '1' || process.env.HEADED === 'true'
this.browser = await chromium.launch({
headless: !headed,
slowMo: headed ? 150 : 0,
})
this.context = await this.browser.newContext({
locale: process.env.E2E_LOCALE ?? 'de-DE',
})
await this.context.addInitScript((apiUrl) => {
;(window as typeof window & { __E2E_API_URL?: string }).__E2E_API_URL = apiUrl
}, config.apiUrl)
this.page = await this.context.newPage()
this.page.setDefaultTimeout(config.responseTimeout)
}
async closeBrowser() {
await this.context?.close()
await this.browser?.close()
}
async gotoApp(path: string) {
const url = path.startsWith('http')
? path
: `${config.appUrl}${path.startsWith('/') ? path : `/${path}`}`
await this.page.goto(url)
await this.page.waitForFunction(
() => document.documentElement.dataset.appHydrated === 'true',
undefined,
{ timeout: config.responseTimeout },
)
await this.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await this.page.waitForTimeout(500)
}
/**
* Fills the login form on /login and waits for navigation away from it.
* Assumes the login form has inputs named/typed `email` and a password
* input. Override per-project if the form differs.
*/
async login(email: string, password: string) {
await this.gotoApp('/login')
await this.page.locator('input[type="email"]').fill(email)
await this.page.locator('input[type="password"]').fill(password)
await this.page.getByRole('button', { name: /(sign in|login|anmelden)/i }).click()
await this.page.waitForURL((url) => !url.pathname.startsWith('/login'), {
timeout: config.responseTimeout,
})
}
async logout() {
await this.gotoApp('/logout')
}
// Calls an RPC through the browser page so session cookies are included.
async callRpc(name: string, data: Record<string, unknown> = {}) {
const apiUrl = config.apiUrl
const result = await this.page.evaluate(
async ({ apiUrl, name, data }) => {
const res = await fetch(`${apiUrl}/rpc/${name}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ data }),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`RPC ${name} failed (${res.status}): ${body}`)
}
return res.json()
},
{ apiUrl, name, data },
)
return result
}
async runLifecycle() {
return this.callRpc('runBookingLifecycleNow', {})
}
async expectText(text: string) {
const locator = this.page.getByText(text, { exact: false })
const deadline = Date.now() + config.responseTimeout
while (Date.now() < deadline) {
const count = await locator.count()
for (let i = 0; i < count; i++) {
if (await locator.nth(i).isVisible().catch(() => false)) {
return
}
}
await this.page.waitForTimeout(100)
}
throw new Error(`Timed out waiting for visible text: ${text}`)
}
async getPageText(): Promise<string> {
return this.page.innerText('body')
}
}
setWorldConstructor(AppWorld)