chore: seminarhof customer project
This commit is contained in:
7
e2e/tests/cucumber.mjs
Normal file
7
e2e/tests/cucumber.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
requireModule: ['tsx'],
|
||||
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
|
||||
paths: ['tests/features/**/*.feature'],
|
||||
format: ['progress', 'html:.reports/cucumber-report.html'],
|
||||
forceExit: true,
|
||||
}
|
||||
0
e2e/tests/features/.gitkeep
Normal file
0
e2e/tests/features/.gitkeep
Normal file
102
e2e/tests/features/admin-booking-detail.feature
Normal file
102
e2e/tests/features/admin-booking-detail.feature
Normal 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 "the admin" is logged in
|
||||
When they visit "/admin/bookings/b_yoga_2026_summer"
|
||||
|
||||
# ── Read ────────────────────────────────────────────────────────────────
|
||||
Scenario: admin opens the summer retreat detail
|
||||
Then they see "Summer Yoga Retreat 2026"
|
||||
And they see "Yoga Retreat e.V."
|
||||
And they see "Teilnehmende"
|
||||
|
||||
Scenario: admin sees the seeded participant list
|
||||
When they click "Teilnehmende"
|
||||
Then they see "Anja Weber"
|
||||
And they see "Bernd Krause"
|
||||
|
||||
# ── Edit booking metadata ───────────────────────────────────────────────
|
||||
Scenario: admin edits the course name and persists across reload
|
||||
When they fill "courseName" with "Summer Yoga Retreat 2026 — revised"
|
||||
And they click "Änderungen speichern"
|
||||
Then they see "Gespeichert"
|
||||
When they visit "/admin/bookings/b_yoga_2026_summer"
|
||||
Then they see "Summer Yoga Retreat 2026 — revised"
|
||||
|
||||
Scenario: admin updates expected participants
|
||||
When they fill "expectedPersons" with "32"
|
||||
And they click "Änderungen speichern"
|
||||
Then they see "Gespeichert"
|
||||
|
||||
Scenario: admin toggles half-house and online listing flags
|
||||
When they select "Ja" from "halfHouse"
|
||||
And they select "Ja" from "onlineAd"
|
||||
And they click "Änderungen speichern"
|
||||
Then they see "Gespeichert"
|
||||
|
||||
Scenario: admin cannot save when no field changed
|
||||
Then they 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 they fill "organisation-email" with "ops@yoga-retreat.example"
|
||||
And they fill "organisation-phone" with "+49 30 12345678"
|
||||
And they click "Organisation speichern"
|
||||
Then they see "Organisation gespeichert"
|
||||
When they 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 they fill "organisation-email" with "not-an-email"
|
||||
And they click "Organisation speichern"
|
||||
Then they do not see "Organisation gespeichert"
|
||||
|
||||
# ── Booking status transitions ─────────────────────────────────────────
|
||||
Scenario: admin cancels a booking
|
||||
# ConfirmButton: first click arms, second click (testid `-confirm`) fires.
|
||||
When they trigger booking action "action-cancel-booking"
|
||||
Then they see "Storniert"
|
||||
And they do not see "Buchung stornieren"
|
||||
|
||||
# ── Participants CRUD ──────────────────────────────────────────────────
|
||||
Scenario: admin adds a participant
|
||||
When they click "Teilnehmende"
|
||||
And they fill "new-participant-name" with "Ingrid Vogel"
|
||||
And they click "Hinzufügen"
|
||||
Then they see "Ingrid Vogel"
|
||||
|
||||
Scenario: admin removes a participant
|
||||
When they click "Teilnehmende"
|
||||
And they accept the next confirmation
|
||||
And they click "remove-participant-p_s_08"
|
||||
Then they do not see "Hans Becker"
|
||||
|
||||
Scenario: admin changes a participant's diet
|
||||
When they click "Teilnehmende"
|
||||
And they 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 they click "Teilnehmende"
|
||||
And they fill "new-allergy-p_s_02" with "shellfish"
|
||||
And they click "add-allergy-p_s_02"
|
||||
Then they see "Meeresfrüchte"
|
||||
|
||||
Scenario: admin removes an existing allergy
|
||||
When they click "Teilnehmende"
|
||||
And they click "remove-allergy-p_s_01-nuts"
|
||||
Then they do not see "nuts"
|
||||
|
||||
# ── Rooms assign / unassign ────────────────────────────────────────────
|
||||
Scenario: admin assigns and then unassigns a participant to a room
|
||||
When they click "Zimmer"
|
||||
And they select "Anja Weber" from "assign-room-r_1"
|
||||
Then they see "Anja Weber"
|
||||
When they click "Entfernen"
|
||||
Then the field "assign-room-r_1" has value ""
|
||||
39
e2e/tests/features/auth.feature
Normal file
39
e2e/tests/features/auth.feature
Normal file
@@ -0,0 +1,39 @@
|
||||
@auth
|
||||
Feature: Authentication
|
||||
Users authenticate with email + password. Sessions are cookie-based.
|
||||
Roles (client / admin / owner) gate access to portals.
|
||||
|
||||
Scenario: client logs in and lands on their portal
|
||||
When "the client" logs in
|
||||
Then the URL contains "/"
|
||||
And they see "Seminarhof Drawehn"
|
||||
|
||||
Scenario: admin logs in and reaches the admin bookings list
|
||||
When "the admin" logs in
|
||||
And they visit "/admin/bookings"
|
||||
Then they see "Buchungen"
|
||||
And they see "Summer Yoga Retreat"
|
||||
|
||||
Scenario: owner logs in and reaches the admin bookings list
|
||||
When "the owner" logs in
|
||||
And they visit "/admin/bookings"
|
||||
Then they see "Buchungen"
|
||||
|
||||
Scenario: wrong password is rejected
|
||||
Given "the client" visits "/login"
|
||||
When they fill "input[type=email]" with "demo@yoga-retreat.example"
|
||||
And they fill "input[type=password]" with "wrong-password"
|
||||
And they click "Anmelden"
|
||||
Then they see "E-Mail oder Passwort ist falsch."
|
||||
|
||||
Scenario: client is redirected away from the admin portal
|
||||
When "the client" logs in
|
||||
And they visit "/admin/bookings"
|
||||
Then the URL does not contain "/admin/"
|
||||
|
||||
Scenario: logout clears the session
|
||||
Given "the client" is logged in
|
||||
When they visit "/logout"
|
||||
Then they see "Sie wurden abgemeldet. Bis bald."
|
||||
When they click "Zurück zur Anmeldung"
|
||||
Then the URL contains "/login"
|
||||
8
e2e/tests/features/availability.feature
Normal file
8
e2e/tests/features/availability.feature
Normal 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 "a visitor" visits "/availability"
|
||||
Then they see "Seminarhof Drawehn"
|
||||
And they see "2026"
|
||||
43
e2e/tests/features/booking-full-flow.feature
Normal file
43
e2e/tests/features/booking-full-flow.feature
Normal file
@@ -0,0 +1,43 @@
|
||||
@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 "a guest" visits "/enquiry"
|
||||
When they fill "courseName" with "E2E Full Flow Test"
|
||||
And they fill "startDate" with "2028-06-15"
|
||||
And they fill "endDate" with "2028-06-22"
|
||||
And they fill "organisationName" with "E2E Organisation"
|
||||
And they fill "contactEmail" with "e2e@example.com"
|
||||
And they turn on "agbAccepted"
|
||||
And they turn on "privacyAccepted"
|
||||
And they click "Anfrage absenden"
|
||||
Then they see "Ihre Anfrage ist eingegangen"
|
||||
|
||||
# ── Admin: full flow from enquiry to confirmed ──────────────────────────
|
||||
Scenario: admin drives a booking from enquiry through to confirmed
|
||||
Given the app data is reset
|
||||
And "the admin" is logged in
|
||||
|
||||
# 1. Open the seeded enquiry directly
|
||||
When they visit "/admin/bookings/b_lc_enquiry"
|
||||
|
||||
# 2. Approve the enquiry → reserved
|
||||
When they trigger booking action "action-approve-enquiry"
|
||||
Then they see "Reserviert"
|
||||
And the audit log tab includes "Reservierung bestätigt"
|
||||
|
||||
# 3. Send the contract manually (bypasses the 365-day cron window)
|
||||
When they trigger booking action "action-send-contract"
|
||||
Then the audit log tab includes "Vertrag & Anzahlungsrechnung"
|
||||
|
||||
# 4. Record the client's contract signature
|
||||
When they trigger booking action "action-contract-approved"
|
||||
Then they see "Anzahlung als erhalten markieren"
|
||||
|
||||
# 5. Record the deposit received → confirmed
|
||||
When they trigger booking action "action-deposit-received"
|
||||
Then they see "Bestätigt"
|
||||
And the audit log tab includes "Buchung bestätigt"
|
||||
26
e2e/tests/features/booking-lifecycle.feature
Normal file
26
e2e/tests/features/booking-lifecycle.feature
Normal file
@@ -0,0 +1,26 @@
|
||||
@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 the app data is reset
|
||||
And "the admin" is logged in
|
||||
|
||||
# ── 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 1 bookings
|
||||
And the booking "b_lc_confirmed" has status "Beendet"
|
||||
13
e2e/tests/features/events.feature
Normal file
13
e2e/tests/features/events.feature
Normal 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 "a visitor" visits "/events"
|
||||
Then they see "Summer Yoga Retreat 2026"
|
||||
And they see "Yoga Retreat e.V."
|
||||
|
||||
Scenario: unpublished retreats stay hidden
|
||||
Given "a visitor" visits "/events"
|
||||
Then they do not see "Winter Stille 2026"
|
||||
30
e2e/tests/features/invoices.feature
Normal file
30
e2e/tests/features/invoices.feature
Normal 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 "the admin" is logged in
|
||||
When they visit "/admin/bookings/b_yoga_2026_summer"
|
||||
And they click "Rechnungen"
|
||||
|
||||
Scenario: admin creates a final invoice
|
||||
When they create an invoice numbered "2026-FIN-002" for 1500.00 €
|
||||
Then they see "2026-FIN-002"
|
||||
And they see "Offen"
|
||||
|
||||
Scenario: admin creates an invoice with an attached PDF
|
||||
When they attach invoice PDF "invoice-sample.pdf"
|
||||
And they create an invoice numbered "2026-PDF-001" for 500.00 €
|
||||
Then they see "PDF öffnen"
|
||||
|
||||
Scenario: admin records a payment on an outstanding invoice
|
||||
When they create an invoice numbered "2026-PAY-001" for 250.00 €
|
||||
And they mark invoice "2026-PAY-001" as paid on "2026-05-10"
|
||||
Then they see "Bezahlt"
|
||||
|
||||
Scenario: admin cancels an outstanding invoice
|
||||
When they create an invoice numbered "2026-CXL-001" for 100.00 €
|
||||
And they accept the next confirmation
|
||||
And they click "invoice-cancel-2026-CXL-001"
|
||||
Then they see "Storniert"
|
||||
36
e2e/tests/fixtures/invoice-sample.pdf
vendored
Normal file
36
e2e/tests/fixtures/invoice-sample.pdf
vendored
Normal 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
|
||||
60
e2e/tests/steps/invoice.steps.ts
Normal file
60
e2e/tests/steps/invoice.steps.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { When } from "@cucumber/cucumber";
|
||||
import path from "node:path";
|
||||
import type { ActorSession } from "@pikku/cucumber/browser";
|
||||
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(
|
||||
"{actor} create(s) an invoice numbered {string} for {float} €",
|
||||
async function (this: AppWorld, actor: ActorSession, 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 = actor.page
|
||||
.getByTestId("invoice-create-form")
|
||||
.locator('input[data-testid="invoice-number"], [data-testid="invoice-number"] input')
|
||||
.first();
|
||||
await numberField.fill(invoiceNumber);
|
||||
const amount = actor.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 actor.page
|
||||
.getByRole("button", { name: /rechnung erstellen|create invoice/i })
|
||||
.click();
|
||||
// Wait for the row to appear in the invoices table above the form.
|
||||
await actor.page.getByText(invoiceNumber, { exact: false }).first().waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
When(
|
||||
"{actor} attach(es) invoice PDF {string}",
|
||||
async function (this: AppWorld, actor: ActorSession, filename: string) {
|
||||
const filePath = path.resolve(process.cwd(), "tests", "fixtures", filename);
|
||||
await actor.page.getByTestId("invoice-pdf").setInputFiles(filePath);
|
||||
},
|
||||
);
|
||||
|
||||
When(
|
||||
"{actor} mark(s) invoice {string} as paid on {string}",
|
||||
async function (this: AppWorld, actor: ActorSession, invoiceNumber: string, paidOn: string) {
|
||||
// Stub the prompt before clicking — the UI uses window.prompt to collect
|
||||
// the payment date.
|
||||
await actor.page.evaluate((value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).prompt = () => value;
|
||||
}, paidOn);
|
||||
|
||||
const row = actor.page.getByRole("row", { name: new RegExp(invoiceNumber) });
|
||||
await row.getByTestId(`invoice-mark-paid-${invoiceNumber}`).click();
|
||||
await row.getByText(/bezahlt|paid/i).waitFor({ state: "visible" });
|
||||
},
|
||||
);
|
||||
114
e2e/tests/steps/lifecycle.steps.ts
Normal file
114
e2e/tests/steps/lifecycle.steps.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { When, Then } from "@cucumber/cucumber";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ActorSession } from "@pikku/cucumber/browser";
|
||||
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();
|
||||
assert.equal(result.contractsSent, count);
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
"the lifecycle cron sent {int} reminders",
|
||||
async function (this: AppWorld, count: number) {
|
||||
const result = await this.runLifecycle();
|
||||
assert.equal(result.reminders, count);
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
"the lifecycle cron completed {int} bookings",
|
||||
async function (this: AppWorld, count: number) {
|
||||
const result = await this.runLifecycle();
|
||||
assert.equal(result.ended, count);
|
||||
},
|
||||
);
|
||||
|
||||
When(
|
||||
"{actor} search(es) the bookings list for {string}",
|
||||
async function (this: AppWorld, actor: ActorSession, text: string) {
|
||||
await actor.page
|
||||
.getByPlaceholder(/suchen/i)
|
||||
.first()
|
||||
.fill(text);
|
||||
await actor.page.waitForTimeout(400); // debounce
|
||||
},
|
||||
);
|
||||
|
||||
When(
|
||||
"{actor} trigger(s) booking action {string}",
|
||||
async function (this: AppWorld, actor: ActorSession, actionId: string) {
|
||||
const action = actor.page.getByTestId(actionId).first();
|
||||
await action.waitFor({ state: "visible", timeout: config.responseTimeout });
|
||||
await action.click({ timeout: config.responseTimeout });
|
||||
const confirm = actor.page.getByTestId(`${actionId}-confirm`).first();
|
||||
await confirm.waitFor({ state: "visible", timeout: config.responseTimeout });
|
||||
await confirm.click({ timeout: config.responseTimeout });
|
||||
await actor.page.waitForLoadState("networkidle", { timeout: 1000 }).catch(() => {
|
||||
// Persistent connections never go idle; the assertion below waits anyway.
|
||||
});
|
||||
await actor.page.waitForTimeout(750);
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
"the audit log tab includes {string}",
|
||||
async function (this: AppWorld, entry: string) {
|
||||
const actor = await this.actor(undefined);
|
||||
await actor.page.getByRole("tab", { name: /protokoll|audit/i }).click();
|
||||
await actor.page
|
||||
.getByText(entry, { exact: false })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: config.responseTimeout });
|
||||
},
|
||||
);
|
||||
|
||||
When(
|
||||
"the admin approves the booking {string}",
|
||||
async function (this: AppWorld, bookingId: string) {
|
||||
const actor = await this.actor(undefined);
|
||||
await actor.gotoApp(`/admin/bookings/${bookingId}`);
|
||||
await actor.page.getByRole("button", { name: /reserv/i }).first().click();
|
||||
await actor.page.waitForTimeout(500);
|
||||
},
|
||||
);
|
||||
|
||||
When(
|
||||
"the admin records the deposit received for {string}",
|
||||
async function (this: AppWorld, bookingId: string) {
|
||||
const actor = await this.actor(undefined);
|
||||
await actor.gotoApp(`/admin/bookings/${bookingId}`);
|
||||
await actor.page.getByRole("button", { name: /anzahlung/i }).first().click();
|
||||
await actor.page.waitForTimeout(500);
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
"the booking {string} has status {string}",
|
||||
async function (this: AppWorld, bookingId: string, status: string) {
|
||||
const actor = await this.actor(undefined);
|
||||
await actor.gotoApp(`/admin/bookings/${bookingId}`);
|
||||
await actor.expectText(status);
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
"the audit log for {string} includes {string}",
|
||||
async function (this: AppWorld, bookingId: string, entry: string) {
|
||||
const actor = await this.actor(undefined);
|
||||
await actor.gotoApp(`/admin/bookings/${bookingId}`);
|
||||
await actor.page.getByRole("tab", { name: /protokoll|audit/i }).click();
|
||||
await actor.page
|
||||
.getByText(entry, { exact: false })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: config.responseTimeout });
|
||||
},
|
||||
);
|
||||
238
e2e/tests/support/hooks.ts
Normal file
238
e2e/tests/support/hooks.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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 { 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 = "bun run 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: "bun run dev:backend",
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
PIKKU_DEV_DB_FILE: scenarioDbPath,
|
||||
// pikku dev reads DATABASE_URL and treats a non-postgres/libsql/http value
|
||||
// as a sqlite file PATH (no scheme). A `file:` prefix isn't stripped and
|
||||
// gets resolveAgainst()'d into a bogus path → fresh empty DB → "no such
|
||||
// table: user". Pass the plain absolute path.
|
||||
DATABASE_URL: scenarioDbPath,
|
||||
// better-auth needs a signing secret to read/issue sessions; provide a
|
||||
// deterministic test value so the managed backend doesn't 500 on auth.
|
||||
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? "e2e-test-better-auth-secret",
|
||||
// Unlocks the testReset RPC ("the app data is reset") on this backend.
|
||||
E2E_TEST_RESET: "1",
|
||||
},
|
||||
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(() => {});
|
||||
});
|
||||
|
||||
// Browser lifecycle (open/close per actor, debug dumps, reset hook) is owned
|
||||
// by @pikku/cucumber/browser — see register.ts. These hooks only manage the
|
||||
// per-scenario backend + DB isolation.
|
||||
// Backend boot occasionally exceeds the default step timeout under load.
|
||||
Before({ timeout: 90_000 }, async function ({ pickle }) {
|
||||
if (config.isolatedDb) {
|
||||
const scenarioId = pickle.id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
await startScenarioBackend(`scenario-${scenarioId}`);
|
||||
}
|
||||
});
|
||||
|
||||
After(async function () {
|
||||
if (config.isolatedDb) {
|
||||
await stopProcess(backendProcess);
|
||||
backendProcess = undefined;
|
||||
if (scenarioDbPath) {
|
||||
await rm(scenarioDbPath, { force: true }).catch(() => {});
|
||||
scenarioDbPath = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
15
e2e/tests/support/register.ts
Normal file
15
e2e/tests/support/register.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
Given,
|
||||
When,
|
||||
Then,
|
||||
Before,
|
||||
After,
|
||||
defineParameterType,
|
||||
setDefaultTimeout,
|
||||
} from "@cucumber/cucumber";
|
||||
import { registerBrowserSteps, registerBrowserHooks } from "@pikku/cucumber/browser";
|
||||
|
||||
// The shared browser vocabulary ({actor} grammar, Mantine drivers, smoke
|
||||
// sweep) and the browser lifecycle hooks. Project steps live in ../steps.
|
||||
registerBrowserSteps({ Given, When, Then, defineParameterType });
|
||||
registerBrowserHooks({ Before, After, setDefaultTimeout });
|
||||
44
e2e/tests/support/types.ts
Normal file
44
e2e/tests/support/types.ts
Normal 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
|
||||
73
e2e/tests/support/world.ts
Normal file
73
e2e/tests/support/world.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { setWorldConstructor } from "@cucumber/cucumber";
|
||||
import {
|
||||
BrowserWorld,
|
||||
browserConfigFromEnv,
|
||||
type ClientContext,
|
||||
} from "@pikku/cucumber/browser";
|
||||
import { PikkuRPC } from "../../../packages/functions-sdk/src/pikku/pikku-rpc.gen.js";
|
||||
import { PikkuFetch } from "../../../packages/functions-sdk/src/pikku/pikku-fetch.gen.js";
|
||||
|
||||
/** The app's generated, fully-typed pikku clients, per actor session. */
|
||||
interface Clients {
|
||||
rpc: PikkuRPC;
|
||||
fetch: PikkuFetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seminarhof world — the shared @pikku/cucumber BrowserWorld (actors, Mantine
|
||||
* steps, page-issue tracking) plus the seminarhof-specific helpers used by
|
||||
* the project steps. Everything generic lives in the package.
|
||||
*/
|
||||
export class AppWorld extends BrowserWorld<Clients> {
|
||||
constructor(options?: unknown) {
|
||||
super(
|
||||
options,
|
||||
browserConfigFromEnv({
|
||||
locale: process.env.E2E_LOCALE ?? "de-DE",
|
||||
// Seeded accounts (db/sqlite-seed.sql) — actors log in by persona.
|
||||
personas: {
|
||||
"the admin": {
|
||||
email: "christina@seminarhof.example",
|
||||
password: "admin1234",
|
||||
name: "Christina Admin",
|
||||
},
|
||||
"the owner": {
|
||||
email: "sarah@seminarhof.example",
|
||||
password: "owner1234",
|
||||
name: "Sarah Drawehn",
|
||||
},
|
||||
"the client": {
|
||||
email: "demo@yoga-retreat.example",
|
||||
password: "demo1234",
|
||||
name: "Demo Organiser",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected createClients({ apiUrl, cookieHeader }: ClientContext): Clients {
|
||||
const pikkuFetch = new PikkuFetch();
|
||||
pikkuFetch.setServerUrl(apiUrl);
|
||||
if (cookieHeader) pikkuFetch.setHeader("Cookie", cookieHeader);
|
||||
const rpc = new PikkuRPC();
|
||||
rpc.setPikkuFetch(pikkuFetch);
|
||||
return { rpc, fetch: pikkuFetch };
|
||||
}
|
||||
|
||||
/** Runs the booking-lifecycle cron once, as the current actor. */
|
||||
async runLifecycle() {
|
||||
const actor = await this.actor(undefined);
|
||||
const { rpc } = await actor.clients();
|
||||
return rpc.invoke("runBookingLifecycleNow", {});
|
||||
}
|
||||
|
||||
/** "the app data is reset" → the app's own testReset RPC (E2E-gated). */
|
||||
override async resetAppData() {
|
||||
const actor = await this.actor(undefined);
|
||||
const { rpc } = await actor.clients();
|
||||
await rpc.invoke("testReset");
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(AppWorld);
|
||||
Reference in New Issue
Block a user