chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:21:55 +02:00
commit 52cd52e41a
293 changed files with 28412 additions and 0 deletions

View File

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

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

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

View File

@@ -0,0 +1,14 @@
Feature: Audit Log
Scenario: Audit log captures changes
Given I login as "admin@perauset.org"
When I update my profile with displayName "Audited Admin"
And I send a GET request to "/audit-log?tableName=user"
Then the response status should be 200
And the audit log should contain an "UPDATE" entry for table "user"
Scenario: Unpermissioned user cannot view audit log
Given a user "noaudit@test.org" exists with roles "guest"
And I login as "noaudit@test.org"
When I send a GET request to "/audit-log"
Then the response status should be 403

View File

@@ -0,0 +1,10 @@
Feature: Authentication
Scenario: Unauthenticated request to protected endpoint
When I send a GET request to "/users"
Then the response status should be 403
Scenario: Login and get session
Given I login as "admin@perauset.org"
When I send a GET request to "/api/auth/get-session"
Then the response status should be 200

View File

@@ -0,0 +1,122 @@
Feature: Boat Management
Scenario: Create route, boat, and trip as coordinator
Given I login as "admin@perauset.org"
When I create a boat route with name "Main Ferry" origin "Mainland" destination "Island"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Sea Spirit" capacity 20
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-06-15T08:00:00Z"
Then the response status should be 200
And the response body field "status" should be "planned"
And I store the response field "tripId" as "tripId"
Scenario: Open trip for requests
Given I login as "admin@perauset.org"
When I create a boat route with name "Express Route" origin "Port A" destination "Port B"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Wave Runner" capacity 15
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-07-01T09:00:00Z"
Then the response status should be 200
And I store the response field "tripId" as "tripId"
When I open the boat trip
Then the response status should be 200
And the response body field "status" should be "open"
Scenario: Request seat as community member
Given I login as "admin@perauset.org"
When I create a boat route with name "Shuttle" origin "Dock A" destination "Dock B"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Island Hopper" capacity 10
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-08-01T07:00:00Z"
Then the response status should be 200
And I store the response field "tripId" as "tripId"
When I open the boat trip
Then the response status should be 200
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request a seat on the boat trip
Then the response status should be 200
And the response body field "status" should be "submitted"
And I store the response field "requestId" as "boatRequestId"
Scenario: Confirm boat request as coordinator
Given I login as "admin@perauset.org"
When I create a boat route with name "Confirm Route" origin "Harbor" destination "Cove"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Coral Cruiser" capacity 12
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-09-01T10:00:00Z"
Then the response status should be 200
And I store the response field "tripId" as "tripId"
When I open the boat trip
Then the response status should be 200
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request a seat on the boat trip
Then the response status should be 200
And I store the response field "requestId" as "boatRequestId"
Given I login as "admin@perauset.org"
When I confirm the boat request
Then the response status should be 200
And the response body field "status" should be "confirmed"
Scenario: Cancel a trip
Given I login as "admin@perauset.org"
When I create a boat route with name "Cancel Route" origin "Pier" destination "Bay"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Sunset Sailor" capacity 8
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-10-01T06:00:00Z"
Then the response status should be 200
And I store the response field "tripId" as "tripId"
When I open the boat trip
Then the response status should be 200
When I cancel the boat trip
Then the response status should be 200
And the response body field "status" should be "cancelled"
Scenario: Unpermissioned user cannot create trips
Given I login as "admin@perauset.org"
When I create a boat route with name "Forbidden Route" origin "X" destination "Y"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a boat trip for the route without boat departing "2026-06-15T08:00:00Z"
Then the response status should be 403
Scenario: Unpermissioned user cannot confirm requests
Given I login as "admin@perauset.org"
When I create a boat route with name "Perm Route" origin "North" destination "South"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Perm Boat" capacity 10
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-11-01T08:00:00Z"
Then the response status should be 200
And I store the response field "tripId" as "tripId"
When I open the boat trip
Then the response status should be 200
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request a seat on the boat trip
Then the response status should be 200
And I store the response field "requestId" as "boatRequestId"
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I confirm the boat request
Then the response status should be 403

View File

@@ -0,0 +1,14 @@
Feature: Finance UI
Background:
Given I am logged in as "admin@perauset.org"
Scenario: Create finance record
When I navigate to "/finance"
And I click "Add Record"
And I fill in "Name" with "Kitchen Supplies"
And I fill in "Amount" with "150"
And I select "Expense" from "Type"
And I select "Kitchen" from "Category"
And I click "Create Record"
Then I should see "Kitchen Supplies"

View File

@@ -0,0 +1,16 @@
Feature: Inventory Management UI
Background:
Given I am logged in as "admin@perauset.org"
Scenario: Add inventory item
When I navigate to "/inventory"
Then I should see heading "Inventory"
And I click "Add Item"
And I fill in "Item Name" with "Olive Oil"
And I fill in "Unit" with "liter"
And I fill in "Current Quantity" with "20"
And I fill in "Minimum Quantity" with "5"
And I select "Kitchen" from "Category"
And I click "Create Item"
Then I should see "Olive Oil"

View File

@@ -0,0 +1,15 @@
Feature: Login
Scenario: Login with valid credentials
Given I am on the login page
Then I should see "PerAuset"
And I should see "Sign in to your account"
When I fill in "Email" with "admin@perauset.org"
And I fill in "Password" with "test"
And I click "Sign in"
Then I should see heading "Dashboard"
And the sidebar should show "Dashboard"
Scenario: Login page pre-fills demo credentials
Given I am on the login page
Then the "Email" field should contain "admin@perauset.org"

View File

@@ -0,0 +1,63 @@
Feature: Navigation
Background:
Given I am logged in as "admin@perauset.org"
Scenario: Dashboard loads
Then I should see heading "Dashboard"
And I should see "Stays"
Scenario: Navigate to Boats
When I click "Boats" in the sidebar
Then I should see heading "Boat Trips"
Scenario: Navigate to Kitchen
When I click "Kitchen" in the sidebar
Then I should see heading "Kitchen"
Scenario: Navigate to Inventory
When I click "Inventory" in the sidebar
Then I should see heading "Inventory"
Scenario: Navigate to Finance
When I click "Finance" in the sidebar
Then I should see heading "Finance"
Scenario: Navigate to Retreats
When I click "Retreats" in the sidebar
Then I should see heading "Retreats"
Scenario: Navigate to Rooms
When I click "Rooms" in the sidebar
Then I should see heading "Rooms"
Scenario: Navigate to Boat Management
When I click "Boat Management" in the sidebar
Then I should see heading "Fleet Management"
Scenario: Navigate to Users
When I click "Users" in the sidebar
Then I should see heading "Users & Roles"
Scenario: Navigate to Audit Log
When I click "Audit Log" in the sidebar
Then I should see heading "Audit Log"
Scenario: Navigate to My Stays
When I click "My Stays" in the sidebar
Then I should see heading "My Stays"
Scenario: Navigate to My Profile
When I click "My Profile" in the sidebar
Then I should see heading "My Profile"
# The Operations "Stays" and "Tasks" sidebar links share their labels with the
# personal "My Stays"/"My Tasks" links (i18n: nav.stays == nav.my_stays), so
# they are exercised via direct URL navigation instead of the ambiguous sidebar.
Scenario: Open the Stays operations page
When I navigate to "/stays"
Then I should see heading "Stays"
Scenario: Open the Tasks operations page
When I navigate to "/tasks"
Then I should see heading "Tasks"

View File

@@ -0,0 +1,15 @@
Feature: Room Management UI
Background:
Given I am logged in as "admin@perauset.org"
Scenario: Create a room
When I navigate to "/rooms"
Then I should see heading "Rooms"
And I click "Create Room"
And I fill in "Room Code" with "R-101"
And I fill in "Room Name" with "Sunrise Room"
And I select "Private" from "Room Type"
And I fill in "Capacity" with "2"
And I click "Save Room"
Then I should see "Sunrise Room"

View File

@@ -0,0 +1,15 @@
Feature: Stay Management UI
Background:
Given I am logged in as "admin@perauset.org"
Scenario: Stay request drawer opens
When I navigate to "/stays"
Then I should see heading "Stays"
When I click "Request a Stay"
Then I should see "Request Type"
Scenario: View stay requests tab
When I navigate to "/stays"
And I click tab "Requests"
Then I should see "No stay requests found."

View File

@@ -0,0 +1,58 @@
Feature: Finance Management
Scenario: Create finance record as admin
Given I login as "admin@perauset.org"
When I create a finance record "Office Supplies" with amount 150 and type "expense"
Then the response status should be 200
And the response body field "name" should be "Office Supplies"
And the response body field "recordType" should be "expense"
And I store the response field "financeId" as "financeId"
Scenario: List finance records
Given I login as "admin@perauset.org"
When I create a finance record "Boat Fuel" with amount 200 and type "expense"
Then the response status should be 200
When I list finance records
Then the response status should be 200
And the response body field "items" should be truthy
Scenario: Update finance record
Given I login as "admin@perauset.org"
When I create a finance record "Garden Tools" with amount 75 and type "expense"
Then the response status should be 200
And I store the response field "financeId" as "financeId"
When I update the finance record with name "Garden Tools (Updated)"
Then the response status should be 200
And the response body field "name" should be "Garden Tools (Updated)"
Scenario: Link finance record to entity
Given I login as "admin@perauset.org"
When I create a finance record "Kitchen Equipment" with amount 500 and type "expense"
Then the response status should be 200
And I store the response field "financeId" as "financeId"
When I create an inventory item "Blender" with unit "pieces" quantity 2 and minimum 1
Then the response status should be 200
And I store the response field "itemId" as "itemId"
When I link the finance record to entity type "inventory_item"
Then the response status should be 200
And the response body field "linkId" should be truthy
Scenario: List links for finance record
Given I login as "admin@perauset.org"
When I create a finance record "Retreat Supplies" with amount 300 and type "expense"
Then the response status should be 200
And I store the response field "financeId" as "financeId"
When I create an inventory item "Yoga Mats" with unit "pieces" quantity 10 and minimum 2
Then the response status should be 200
And I store the response field "itemId" as "itemId"
When I link the finance record to entity type "inventory_item"
Then the response status should be 200
When I list finance links for the current finance record
Then the response status should be 200
And the response body field "items" should be truthy
Scenario: Unpermissioned user cannot create finance records
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a finance record "Forbidden Record" with amount 100 and type "expense"
Then the response status should be 403

View File

@@ -0,0 +1,6 @@
Feature: Health Check
The health check endpoint should return service status
Scenario: Health check returns ok
When I send a GET request to "/health-check"
Then the response status should be 200

View File

@@ -0,0 +1,77 @@
Feature: Inventory Management
Scenario: Create inventory item as admin
Given I login as "admin@perauset.org"
When I create an inventory item "Rice" with unit "kg" quantity 100 and minimum 10
Then the response status should be 200
And the response body field "name" should be "Rice"
And the response body field "unit" should be "kg"
And the response body field "isActive" should be truthy
And I store the response field "itemId" as "itemId"
Scenario: Update inventory item as admin
Given I login as "admin@perauset.org"
When I create an inventory item "Flour" with unit "kg" quantity 50 and minimum 5
Then the response status should be 200
And I store the response field "itemId" as "itemId"
When I update the inventory item with name "Whole Wheat Flour"
Then the response status should be 200
And the response body field "name" should be "Whole Wheat Flour"
Scenario: List inventory items
Given I login as "admin@perauset.org"
When I create an inventory item "Olive Oil" with unit "liters" quantity 20 and minimum 5
Then the response status should be 200
When I list inventory items
Then the response status should be 200
And the response body field "items" should be truthy
Scenario: Request inventory item as community member
Given I login as "admin@perauset.org"
When I create an inventory item "Sugar" with unit "kg" quantity 30 and minimum 5
Then the response status should be 200
And I store the response field "itemId" as "itemId"
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request inventory for item with quantity 5 unit "kg" and purpose "Baking"
Then the response status should be 200
And the response body field "status" should be "submitted"
And I store the response field "requestId" as "requestId"
Scenario: Review inventory request as admin
Given I login as "admin@perauset.org"
When I create an inventory item "Salt" with unit "kg" quantity 20 and minimum 2
Then the response status should be 200
And I store the response field "itemId" as "itemId"
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request inventory for item with quantity 3 unit "kg" and purpose "Cooking"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I review the inventory request with status "approved"
Then the response status should be 200
And the response body field "status" should be "approved"
Scenario: Fulfill inventory request as admin
Given I login as "admin@perauset.org"
When I create an inventory item "Pepper" with unit "kg" quantity 15 and minimum 2
Then the response status should be 200
And I store the response field "itemId" as "itemId"
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request inventory for item with quantity 2 unit "kg" and purpose "Seasoning"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I review the inventory request with status "approved"
Then the response status should be 200
When I fulfill the inventory request
Then the response status should be 200
And the response body field "status" should be "fulfilled"
Scenario: Unpermissioned user cannot create inventory items
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create an inventory item "Forbidden Item" with unit "kg" quantity 10 and minimum 1
Then the response status should be 403

View File

@@ -0,0 +1,29 @@
Feature: Kitchen Management
Scenario: Create meal service as admin
Given I login as "admin@perauset.org"
When I create a meal service of type "lunch" on "2026-07-01" with headcount 20
Then the response status should be 200
And the response body field "status" should be "planned"
And I store the response field "serviceId" as "serviceId"
Scenario: List meal services
Given I login as "admin@perauset.org"
When I create a meal service of type "breakfast" on "2026-07-02" with headcount 15
Then the response status should be 200
When I list meal services
Then the response status should be 200
And the response body field "items" should be truthy
Scenario: Upsert dietary profile
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I upsert my dietary profile as vegan with nut allergy
Then the response status should be 200
And the response body field "profileId" should be truthy
Scenario: Unpermissioned user cannot create meal services
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a meal service of type "dinner" on "2026-07-03" with headcount 10
Then the response status should be 403

View File

@@ -0,0 +1,14 @@
Feature: Notification Management
Scenario: List notifications
Given I login as "admin@perauset.org"
When I send a GET request to "/notifications"
Then the response status should be 200
And the response body should be a list with field "items"
Scenario: Mark notification as read
Given I login as "admin@perauset.org"
And a test notification exists for the current user
When I mark the notification as read
Then the response status should be 200
And the response body field "success" should be truthy

View File

@@ -0,0 +1,68 @@
Feature: Retreat Management
Scenario: Create retreat as admin
Given I login as "admin@perauset.org"
When I create a retreat "Summer Solstice" with slug "summer-solstice-2026" from "2026-06-20" to "2026-06-22"
Then the response status should be 200
And the response body field "status" should be "draft"
And I store the response field "retreatId" as "retreatId"
Scenario: List retreats
Given I login as "admin@perauset.org"
When I create a retreat "Autumn Equinox" with slug "autumn-equinox-2026" from "2026-09-22" to "2026-09-24"
Then the response status should be 200
When I list retreats
Then the response status should be 200
And the response body field "items" should be truthy
Scenario: Update retreat
Given I login as "admin@perauset.org"
When I create a retreat "Spring Retreat" with slug "spring-retreat-2026" from "2026-03-20" to "2026-03-22"
Then the response status should be 200
And I store the response field "retreatId" as "retreatId"
When I update the retreat with name "Spring Renewal Retreat" and capacity 30
Then the response status should be 200
And the response body field "success" should be truthy
Scenario: Publish retreat
Given I login as "admin@perauset.org"
When I create a retreat "Winter Solstice" with slug "winter-solstice-2026" from "2026-12-20" to "2026-12-22"
Then the response status should be 200
And I store the response field "retreatId" as "retreatId"
When I publish the retreat
Then the response status should be 200
And the response body field "status" should be "published"
Scenario: Add person to retreat
Given I login as "admin@perauset.org"
When I create a retreat "Meditation Retreat" with slug "meditation-2026" from "2026-08-01" to "2026-08-03"
Then the response status should be 200
And I store the response field "retreatId" as "retreatId"
When I add a person "Jane Doe" with role "participant" to the retreat
Then the response status should be 200
And the response body field "attendanceStatus" should be "registered"
And I store the response field "retreatPersonId" as "retreatPersonId"
Scenario: Add schedule item to retreat
Given I login as "admin@perauset.org"
When I create a retreat "Yoga Retreat" with slug "yoga-retreat-2026" from "2026-10-01" to "2026-10-03"
Then the response status should be 200
And I store the response field "retreatId" as "retreatId"
When I add a schedule item "Morning Yoga" from "2026-10-01T07:00:00Z" to "2026-10-01T08:30:00Z" at "Main Hall"
Then the response status should be 200
And the response body field "itemId" should be truthy
Scenario: Cancel retreat
Given I login as "admin@perauset.org"
When I create a retreat "Cancelled Event" with slug "cancelled-event-2026" from "2026-11-01" to "2026-11-03"
Then the response status should be 200
And I store the response field "retreatId" as "retreatId"
When I cancel the retreat
Then the response status should be 200
And the response body field "status" should be "cancelled"
Scenario: Unpermissioned user cannot create retreats
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a retreat "Forbidden Retreat" with slug "forbidden-2026" from "2026-07-01" to "2026-07-03"
Then the response status should be 403

View File

@@ -0,0 +1,20 @@
Feature: Role Management
Scenario: List all roles
Given I login as "admin@perauset.org"
When I send a GET request to "/roles"
Then the response status should be 200
And the response body should be a list with field "roles"
Scenario: Set user roles
Given I login as "admin@perauset.org"
And a user "vol@test.org" exists
When I set roles "volunteer" for user "vol@test.org"
Then the response status should be 200
And the response body field "memberRoles" should contain "volunteer"
Scenario: Unpermissioned user cannot set roles
Given a user "norole@test.org" exists with roles "guest"
And I login as "norole@test.org"
When I set roles "admin" for user "norole@test.org"
Then the response status should be 403

View File

@@ -0,0 +1,105 @@
Feature: Room Management
Scenario: Create a room as coordinator
Given I login as "admin@perauset.org"
When I create a room with code "R101" name "Room 101" type "private" capacity 2
Then the response status should be 200
And I store the response field "roomId" as "roomId"
Scenario: Allocate room to a stay
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "guest" from "2026-06-01" to "2026-06-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId"
When I create a room with code "R201" name "Room 201" type "shared" capacity 4
Then the response status should be 200
And I store the response field "roomId" as "roomId"
When I allocate the room to the stay from "2026-06-01" to "2026-06-10"
Then the response status should be 200
And I store the response field "allocationId" as "allocationId"
Scenario: Double allocation for overlapping dates fails
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "guest" from "2026-07-01" to "2026-07-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId1"
Given I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-07-05" to "2026-07-15"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId2"
When I create a room with code "R301" name "Room 301" type "dorm" capacity 6
Then the response status should be 200
And I store the response field "roomId" as "roomId"
When I allocate the room to stored stay "stayId1" from "2026-07-01" to "2026-07-10"
Then the response status should be 200
When I allocate the room to stored stay "stayId2" from "2026-07-05" to "2026-07-15"
Then the response status should be 409
Scenario: Release room allocation
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "guest" from "2026-08-01" to "2026-08-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId"
When I create a room with code "R401" name "Room 401" type "facilitator" capacity 1
Then the response status should be 200
And I store the response field "roomId" as "roomId"
When I allocate the room to the stay from "2026-08-01" to "2026-08-10"
Then the response status should be 200
And I store the response field "allocationId" as "allocationId"
Given the room allocation is active in the database
When I release the room allocation
Then the response status should be 200
And the response body field "status" should be "completed"
Scenario: Unpermissioned user cannot create rooms
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a room with code "R501" name "Room 501" type "dorm" capacity 4
Then the response status should be 403
Scenario: Unpermissioned user cannot allocate rooms
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "guest" from "2026-09-01" to "2026-09-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId"
When I create a room with code "R501" name "Room 501" type "dorm" capacity 4
Then the response status should be 200
And I store the response field "roomId" as "roomId"
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I allocate the room to the stay from "2026-09-01" to "2026-09-10"
Then the response status should be 403

View File

@@ -0,0 +1,102 @@
Feature: Stay Management
Scenario: Submit a stay request as community member
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-06-01" to "2026-06-15"
Then the response status should be 200
And the response body field "status" should be "submitted"
And I store the response field "requestId" as "requestId"
Scenario: Approve a stay request as coordinator
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-07-01" to "2026-07-15"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
And the response body field "status" should be "approved"
Scenario: Create stay from approved request
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "guest" from "2026-08-01" to "2026-08-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And the response body field "status" should be "pending"
And I store the response field "stayId" as "stayId"
Scenario: Check in and check out a stay
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-09-01" to "2026-09-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId"
Given the stay is confirmed in the database
When I check in the stay
Then the response status should be 200
And the response body field "status" should be "checked_in"
When I check out the stay
Then the response status should be 200
And the response body field "status" should be "checked_out"
Scenario: Reject a stay request
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "day_visit" from "2026-10-01" to "2026-10-01"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I reject the stay request with reason "Not available"
Then the response status should be 200
And the response body field "status" should be "rejected"
Scenario: Invalid transition - reject an already approved request
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-11-01" to "2026-11-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I reject the stay request with reason "Changed mind"
Then the response status should be 409
Scenario: Unpermissioned user cannot approve requests
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-12-01" to "2026-12-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I approve the stay request
Then the response status should be 403
Scenario: Unpermissioned user cannot create stays
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2027-01-01" to "2027-01-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a stay from the approved request
Then the response status should be 403

View File

@@ -0,0 +1,87 @@
Feature: Task Management
Scenario: Create task template as coordinator
Given I login as "admin@perauset.org"
When I create a task template with title "Kitchen Cleanup" category "kitchen"
Then the response status should be 200
And I store the response field "templateId" as "templateId"
Scenario: Create task instance
Given I login as "admin@perauset.org"
When I create a task instance with title "Morning Dishes" category "kitchen"
Then the response status should be 200
And the response body field "status" should be "open"
And I store the response field "taskId" as "taskId"
Scenario: Assign task to a user
Given a user "volunteer@test.org" exists with roles "community_member"
And I login as "admin@perauset.org"
When I create a task instance with title "Fix Faucet" category "maintenance"
Then the response status should be 200
And I store the response field "taskId" as "taskId"
When I assign the task to user "volunteer@test.org"
Then the response status should be 200
And the response body field "taskStatus" should be "assigned"
Scenario: Start a task
Given I login as "admin@perauset.org"
When I create a task instance with title "Mow Lawn" category "maintenance"
Then the response status should be 200
And I store the response field "taskId" as "taskId"
Given a user "volunteer@test.org" exists with roles "community_member"
When I assign the task to user "volunteer@test.org"
Then the response status should be 200
When I start the task
Then the response status should be 200
And the response body field "status" should be "in_progress"
Scenario: Complete a task
Given I login as "admin@perauset.org"
When I create a task instance with title "Paint Fence" category "maintenance"
Then the response status should be 200
And I store the response field "taskId" as "taskId"
Given a user "volunteer@test.org" exists with roles "community_member"
When I assign the task to user "volunteer@test.org"
Then the response status should be 200
When I start the task
Then the response status should be 200
When I complete the task
Then the response status should be 200
And the response body field "status" should be "completed"
Scenario: Claim task as volunteer
Given I login as "admin@perauset.org"
When I create a task instance with title "Sweep Patio" category "housekeeping"
Then the response status should be 200
And I store the response field "taskId" as "taskId"
Given a user "volunteer@test.org" exists with roles "community_member"
And I login as "volunteer@test.org"
When I claim the task
Then the response status should be 200
And the response body field "taskStatus" should be "assigned"
Scenario: Block a task
Given I login as "admin@perauset.org"
When I create a task instance with title "Repair Roof" category "maintenance"
Then the response status should be 200
And I store the response field "taskId" as "taskId"
Given a user "volunteer@test.org" exists with roles "community_member"
When I assign the task to user "volunteer@test.org"
Then the response status should be 200
When I mark the task as blocked with reason "Waiting for materials"
Then the response status should be 200
And the response body field "status" should be "blocked"
Scenario: Invalid transition - complete an open task
Given I login as "admin@perauset.org"
When I create a task instance with title "Invalid Task" category "operations"
Then the response status should be 200
And I store the response field "taskId" as "taskId"
When I complete the task
Then the response status should be 409
Scenario: Unpermissioned user cannot create templates
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a task template with title "Forbidden Template" category "kitchen"
Then the response status should be 403

View File

@@ -0,0 +1,24 @@
Feature: User Management
Scenario: List users as admin
Given I login as "admin@perauset.org"
When I send a GET request to "/users"
Then the response status should be 200
And the response body should be a list with field "items"
Scenario: Get own user profile
Given I login as "admin@perauset.org"
When I get my own user profile
Then the response status should be 200
And the response body field "email" should be "admin@perauset.org"
Scenario: Update own profile
Given I login as "admin@perauset.org"
When I update my profile with displayName "Island Admin"
Then the response status should be 200
Scenario: Unpermissioned user cannot list users
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I send a GET request to "/users"
Then the response status should be 403

View File

@@ -0,0 +1,12 @@
import { Then } from '@cucumber/cucumber'
import { strict as assert } from 'assert'
import type { PerausetWorld } from '../support/world.js'
Then('the audit log should contain an {string} entry for table {string}', function (this: PerausetWorld, action: string, tableName: string) {
assert.ok(this.lastBody, 'No response body')
assert.ok(Array.isArray(this.lastBody.items), `Expected body.items to be an array, got: ${JSON.stringify(this.lastBody)}`)
const match = this.lastBody.items.find(
(entry: any) => entry.action === action && entry.tableName === tableName
)
assert.ok(match, `No audit log entry with action="${action}" for table="${tableName}" found in: ${JSON.stringify(this.lastBody.items.slice(0, 5))}`)
})

View File

@@ -0,0 +1,6 @@
import { Given } from '@cucumber/cucumber'
import type { PerausetWorld } from '../support/world.js'
Given('I login as {string}', async function (this: PerausetWorld, email: string) {
await this.login(email)
})

View File

@@ -0,0 +1,64 @@
import { When } from '@cucumber/cucumber'
import type { PerausetWorld } from '../support/world.js'
When('I create a boat route with name {string} origin {string} destination {string}', async function (this: PerausetWorld, name: string, origin: string, destination: string) {
await this.post('/boats/routes', {
name,
origin,
destination,
})
})
When('I create a boat with name {string} capacity {int}', async function (this: PerausetWorld, name: string, capacity: number) {
await this.post('/boats', {
name,
passengerCapacity: capacity,
})
})
When('I create a boat trip for the route and boat departing {string}', async function (this: PerausetWorld, departureAt: string) {
const routeId = (this as any).routeId
const boatId = (this as any).boatId
await this.post('/boats/trips', {
routeId,
boatId,
scheduledDepartureAt: departureAt,
})
})
When('I open the boat trip', async function (this: PerausetWorld) {
const tripId = (this as any).tripId
await this.post(`/boats/trips/${tripId}/open`, {
tripId,
})
})
When('I request a seat on the boat trip', async function (this: PerausetWorld) {
const tripId = (this as any).tripId
await this.post(`/boats/trips/${tripId}/request-seat`, {
tripId,
requestedSeats: 1,
})
})
When('I confirm the boat request', async function (this: PerausetWorld) {
const boatRequestId = (this as any).boatRequestId
await this.post(`/boats/requests/${boatRequestId}/confirm`, {
requestId: boatRequestId,
})
})
When('I create a boat trip for the route without boat departing {string}', async function (this: PerausetWorld, departureAt: string) {
const routeId = (this as any).routeId
await this.post('/boats/trips', {
routeId,
scheduledDepartureAt: departureAt,
})
})
When('I cancel the boat trip', async function (this: PerausetWorld) {
const tripId = (this as any).tripId
await this.post(`/boats/trips/${tripId}/cancel`, {
tripId,
})
})

View File

@@ -0,0 +1,141 @@
import { Given, When, Then } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import { BrowserWorld } from '../../support/browser-world.js'
import { browserConfig } from '../../support/browser-config.js'
// ---------------------------------------------------------------------------
// Given
// ---------------------------------------------------------------------------
Given('I am on the login page', async function (this: BrowserWorld) {
await this.navigateTo('/login')
})
Given('I am logged in as {string}', async function (this: BrowserWorld, email: string) {
await this.login(email)
})
// ---------------------------------------------------------------------------
// When — navigation
// ---------------------------------------------------------------------------
When('I navigate to {string}', async function (this: BrowserWorld, path: string) {
await this.navigateTo(path)
})
When('I click {string}', async function (this: BrowserWorld, name: string) {
const page = this.getPage()
// Try button first, then link
const button = page.getByRole('button', { name })
if (await button.isVisible().catch(() => false)) {
await button.click()
} else {
await page.getByRole('link', { name }).click()
}
await page.waitForLoadState('networkidle').catch(() => {})
})
When('I click {string} in the sidebar', async function (this: BrowserWorld, name: string) {
const page = this.getPage()
// Mantine NavLink renders as an <a> WITHOUT href (navigation is via onClick), so
// it has no implicit "link" role — target the label text inside the navbar.
// Some labels are duplicated (e.g. "My Stays" maps to both /my/stays and /stays
// due to the nav.stays == nav.my_stays i18n collision); first() picks the
// personal-section entry, which appears first in DOM order.
const navItem = page
.locator('.mantine-AppShell-navbar')
.getByText(name, { exact: true })
.first()
await navItem.scrollIntoViewIfNeeded()
await navItem.click()
await page.waitForLoadState('networkidle').catch(() => {})
})
When('I click tab {string}', async function (this: BrowserWorld, name: string) {
const page = this.getPage()
await page.getByRole('tab', { name }).click()
await page.waitForLoadState('networkidle').catch(() => {})
})
// ---------------------------------------------------------------------------
// When — form interactions
// ---------------------------------------------------------------------------
When(
'I fill in {string} with {string}',
async function (this: BrowserWorld, label: string, value: string) {
await this.fillField(label, value)
}
)
When(
'I select {string} from {string}',
async function (this: BrowserWorld, value: string, label: string) {
await this.selectOption(label, value)
}
)
When(
'I pick date {string} for {string}',
async function (this: BrowserWorld, day: string, label: string) {
const page = this.getPage()
// Close any open calendar/dropdown first
await page.keyboard.press('Escape')
await page.waitForTimeout(300)
// Mantine DatePickerInput: the label text and button are wrapped in the same container
// Use a CSS selector to find the right DatePickerInput by its label
const wrapper = page.locator(`text="${label}" >> xpath=ancestor::div[contains(@class, "mantine-DatePickerInput-root") or contains(@class, "mantine-InputWrapper-root")][1]`)
const btn = wrapper.locator('button').first()
if (await btn.count() > 0) {
await btn.click({ timeout: 10_000 })
} else {
// Fallback: just try getByLabel
await page.getByLabel(label).click({ timeout: 10_000 })
}
// Wait for calendar popover to appear
await page.waitForTimeout(500)
// Click the day number — try table buttons with matching text
await page.locator('table button').filter({ hasText: new RegExp(`^${day}$`) }).first().click()
// Close the calendar popover
await page.waitForTimeout(300)
}
)
// ---------------------------------------------------------------------------
// Then — assertions
// ---------------------------------------------------------------------------
Then('I should see {string}', async function (this: BrowserWorld, text: string) {
await this.expectText(text)
})
Then('I should not see {string}', async function (this: BrowserWorld, text: string) {
await this.expectNoText(text)
})
Then('I should see heading {string}', async function (this: BrowserWorld, text: string) {
await this.expectHeading(text)
})
Then('I should be on {string}', async function (this: BrowserWorld, path: string) {
const page = this.getPage()
await page.waitForURL((url) => url.pathname.includes(path), { timeout: 10_000 })
expect(page.url()).toContain(path)
})
Then(
'the sidebar should show {string}',
async function (this: BrowserWorld, text: string) {
const page = this.getPage()
const sidebar = page.locator('.mantine-AppShell-navbar')
await expect(sidebar.getByText(text)).toBeVisible({ timeout: 10_000 })
}
)
Then(
'the {string} field should contain {string}',
async function (this: BrowserWorld, label: string, value: string) {
const page = this.getPage()
await expect(page.getByLabel(label)).toHaveValue(value, { timeout: 5_000 })
}
)

View File

@@ -0,0 +1,38 @@
import { When } from '@cucumber/cucumber'
import type { PerausetWorld } from '../support/world.js'
When('I create a finance record {string} with amount {int} and type {string}', async function (this: PerausetWorld, name: string, amount: number, recordType: string) {
await this.post('/finance', {
name,
amount,
recordType,
category: 'operations',
})
})
When('I list finance records', async function (this: PerausetWorld) {
await this.get('/finance')
})
When('I update the finance record with name {string}', async function (this: PerausetWorld, name: string) {
const financeId = (this as any).financeId
await this.put(`/finance/${financeId}`, {
financeId,
name,
})
})
When('I link the finance record to entity type {string}', async function (this: PerausetWorld, entityType: string) {
const financeId = (this as any).financeId
const entityId = (this as any).itemId
await this.post('/finance/links', {
financeId,
entityType,
entityId,
})
})
When('I list finance links for the current finance record', async function (this: PerausetWorld) {
const financeId = (this as any).financeId
await this.get(`/finance/links?financeId=${financeId}`)
})

View File

@@ -0,0 +1,10 @@
import { When, Then } from '@cucumber/cucumber'
import type { PerausetWorld } from '../support/world.js'
When('I send a GET request to {string}', async function (this: PerausetWorld, path: string) {
await this.get(path)
})
Then('the response status should be {int}', function (this: PerausetWorld, status: number) {
this.expectStatus(status)
})

View File

@@ -0,0 +1,49 @@
import { When } from '@cucumber/cucumber'
import type { PerausetWorld } from '../support/world.js'
When('I create an inventory item {string} with unit {string} quantity {int} and minimum {int}', async function (this: PerausetWorld, name: string, unit: string, currentQuantity: number, minimumQuantity: number) {
await this.post('/inventory', {
name,
unit,
currentQuantity,
minimumQuantity,
category: 'kitchen',
})
})
When('I update the inventory item with name {string}', async function (this: PerausetWorld, name: string) {
const itemId = (this as any).itemId
await this.put(`/inventory/${itemId}`, {
itemId,
name,
})
})
When('I list inventory items', async function (this: PerausetWorld) {
await this.get('/inventory')
})
When('I request inventory for item with quantity {int} unit {string} and purpose {string}', async function (this: PerausetWorld, quantity: number, unit: string, purpose: string) {
const itemId = (this as any).itemId
await this.post('/inventory/requests', {
itemId,
quantity,
unit,
purpose,
})
})
When('I review the inventory request with status {string}', async function (this: PerausetWorld, status: string) {
const requestId = (this as any).requestId
await this.post(`/inventory/requests/${requestId}/review`, {
requestId,
status,
})
})
When('I fulfill the inventory request', async function (this: PerausetWorld) {
const requestId = (this as any).requestId
await this.post(`/inventory/requests/${requestId}/fulfill`, {
requestId,
})
})

View File

@@ -0,0 +1,22 @@
import { When } from '@cucumber/cucumber'
import type { PerausetWorld } from '../support/world.js'
When('I create a meal service of type {string} on {string} with headcount {int}', async function (this: PerausetWorld, serviceType: string, serviceDate: string, plannedHeadcount: number) {
await this.post('/kitchen/meal-services', {
serviceType,
serviceDate,
plannedHeadcount,
})
})
When('I list meal services', async function (this: PerausetWorld) {
await this.get('/kitchen/meal-services')
})
When('I upsert my dietary profile as vegan with nut allergy', async function (this: PerausetWorld) {
await this.put('/kitchen/dietary-profile', {
isVegan: true,
hasNutAllergy: true,
allergyNotes: 'Severe nut allergy',
})
})

View File

@@ -0,0 +1,29 @@
import { Given, When } from '@cucumber/cucumber'
import { strict as assert } from 'assert'
import type { PerausetWorld } from '../support/world.js'
import { openDb } from '../support/db.js'
Given('a test notification exists for the current user', async function (this: PerausetWorld) {
const userId = await this.getSessionUserId()
const db = openDb()
try {
const row = db
.prepare(
`INSERT INTO notification (user_id, type, title, body)
VALUES (?, 'test', 'Test Notification', 'This is a test notification')
RETURNING notification_id`
)
.get(userId) as { notification_id: string }
;(this as any).notificationId = row.notification_id
} finally {
db.close()
}
})
When('I mark the notification as read', async function (this: PerausetWorld) {
const notificationId = (this as any).notificationId
assert.ok(notificationId, 'No notification ID stored')
await this.post(`/notifications/${notificationId}/read`, {
notificationId,
})
})

View File

@@ -0,0 +1,59 @@
import { When } from '@cucumber/cucumber'
import type { PerausetWorld } from '../support/world.js'
When('I create a retreat {string} with slug {string} from {string} to {string}', async function (this: PerausetWorld, name: string, slug: string, startAt: string, endAt: string) {
await this.post('/retreats', {
slug,
name,
startAt,
endAt,
})
})
When('I list retreats', async function (this: PerausetWorld) {
await this.get('/retreats')
})
When('I update the retreat with name {string} and capacity {int}', async function (this: PerausetWorld, name: string, capacity: number) {
const retreatId = (this as any).retreatId
await this.put(`/retreats/${retreatId}`, {
retreatId,
name,
capacity,
})
})
When('I publish the retreat', async function (this: PerausetWorld) {
const retreatId = (this as any).retreatId
await this.post(`/retreats/${retreatId}/publish`, {
retreatId,
})
})
When('I add a person {string} with role {string} to the retreat', async function (this: PerausetWorld, fullName: string, roleType: string) {
const retreatId = (this as any).retreatId
await this.post(`/retreats/${retreatId}/persons`, {
retreatId,
fullName,
roleType,
email: `${fullName.toLowerCase().replace(/\s+/g, '.')}@test.org`,
})
})
When('I add a schedule item {string} from {string} to {string} at {string}', async function (this: PerausetWorld, title: string, startsAt: string, endsAt: string, location: string) {
const retreatId = (this as any).retreatId
await this.post(`/retreats/${retreatId}/schedule`, {
retreatId,
title,
startsAt,
endsAt,
location,
})
})
When('I cancel the retreat', async function (this: PerausetWorld) {
const retreatId = (this as any).retreatId
await this.post(`/retreats/${retreatId}/cancel`, {
retreatId,
})
})

View File

@@ -0,0 +1,22 @@
import { When, Then } from '@cucumber/cucumber'
import { strict as assert } from 'assert'
import type { PerausetWorld } from '../support/world.js'
import { getUserId } from '../support/db.js'
When('I set roles {string} for user {string}', async function (this: PerausetWorld, roles: string, email: string) {
// Look up userId from DB if not already cached
let userId = this.createdUsers[email]
if (!userId) {
userId = getUserId(email)
this.createdUsers[email] = userId
}
const rolesArray = roles.split(',').map((r) => r.trim())
await this.put(`/users/${userId}/roles`, { memberRoles: rolesArray })
})
Then('the response body field {string} should contain {string}', function (this: PerausetWorld, field: string, value: string) {
assert.ok(this.lastBody, 'No response body')
const arr = this.lastBody[field]
assert.ok(Array.isArray(arr), `Expected body.${field} to be an array, got: ${JSON.stringify(arr)}`)
assert.ok(arr.includes(value), `Expected body.${field} to contain "${value}", got: ${JSON.stringify(arr)}`)
})

View File

@@ -0,0 +1,51 @@
import { When, Given } from '@cucumber/cucumber'
import type { PerausetWorld } from '../support/world.js'
import { openDb } from '../support/db.js'
When('I create a room with code {string} name {string} type {string} capacity {int}', async function (this: PerausetWorld, code: string, name: string, roomType: string, capacity: number) {
await this.post('/rooms', {
code,
name,
roomType,
capacity,
})
})
When('I allocate the room to the stay from {string} to {string}', async function (this: PerausetWorld, startsAt: string, endsAt: string) {
const roomId = (this as any).roomId
const stayId = (this as any).stayId
await this.post('/rooms/allocations', {
roomId,
stayId,
startsAt,
endsAt,
})
})
When('I allocate the room to stored stay {string} from {string} to {string}', async function (this: PerausetWorld, stayKey: string, startsAt: string, endsAt: string) {
const roomId = (this as any).roomId
const stayId = (this as any)[stayKey]
await this.post('/rooms/allocations', {
roomId,
stayId,
startsAt,
endsAt,
})
})
Given('the room allocation is active in the database', async function (this: PerausetWorld) {
const allocationId = (this as any).allocationId
const db = openDb()
try {
db.prepare(`UPDATE room_allocation SET status = 'active' WHERE allocation_id = ?`).run(allocationId)
} finally {
db.close()
}
})
When('I release the room allocation', async function (this: PerausetWorld) {
const allocationId = (this as any).allocationId
await this.post(`/rooms/allocations/${allocationId}/release`, {
allocationId,
})
})

View File

@@ -0,0 +1,19 @@
import { When, Then } from '@cucumber/cucumber'
import { strict as assert } from 'assert'
import type { PerausetWorld } from '../support/world.js'
When('I send a POST request to {string} with body:', async function (this: PerausetWorld, path: string, body: string) {
await this.post(path, JSON.parse(body))
})
Then('I store the response field {string} as {string}', function (this: PerausetWorld, field: string, key: string) {
assert.ok(this.lastBody, 'No response body')
const value = this.lastBody[field]
assert.ok(value !== undefined && value !== null, `Response body field "${field}" is missing. Body: ${JSON.stringify(this.lastBody)}`)
;(this as any)[key] = value
})
Then('the response body field {string} should be truthy', function (this: PerausetWorld, field: string) {
assert.ok(this.lastBody, 'No response body')
assert.ok(this.lastBody[field], `Expected body.${field} to be truthy, got: ${JSON.stringify(this.lastBody[field])}`)
})

View File

@@ -0,0 +1,57 @@
import { When, Given } from '@cucumber/cucumber'
import type { PerausetWorld } from '../support/world.js'
import { openDb } from '../support/db.js'
When('I submit a stay request with type {string} from {string} to {string}', async function (this: PerausetWorld, requestType: string, startAt: string, endAt: string) {
await this.post('/stays/requests', {
requestType,
requestedStartAt: startAt,
requestedEndAt: endAt,
})
})
When('I approve the stay request', async function (this: PerausetWorld) {
const requestId = (this as any).requestId
await this.post(`/stays/requests/${requestId}/approve`, {
requestId,
})
})
When('I reject the stay request with reason {string}', async function (this: PerausetWorld, reason: string) {
const requestId = (this as any).requestId
await this.post(`/stays/requests/${requestId}/reject`, {
requestId,
reviewNotes: reason,
})
})
When('I create a stay from the approved request', async function (this: PerausetWorld) {
const requestId = (this as any).requestId
await this.post('/stays', {
requestId,
})
})
Given('the stay is confirmed in the database', async function (this: PerausetWorld) {
const stayId = (this as any).stayId
const db = openDb()
try {
db.prepare(`UPDATE stay SET status = 'confirmed' WHERE stay_id = ?`).run(stayId)
} finally {
db.close()
}
})
When('I check in the stay', async function (this: PerausetWorld) {
const stayId = (this as any).stayId
await this.post(`/stays/${stayId}/check-in`, {
stayId,
})
})
When('I check out the stay', async function (this: PerausetWorld) {
const stayId = (this as any).stayId
await this.post(`/stays/${stayId}/check-out`, {
stayId,
})
})

View File

@@ -0,0 +1,63 @@
import { When } from '@cucumber/cucumber'
import { strict as assert } from 'assert'
import type { PerausetWorld } from '../support/world.js'
import { getUserId } from '../support/db.js'
When('I create a task template with title {string} category {string}', async function (this: PerausetWorld, title: string, category: string) {
await this.post('/tasks/templates', {
title,
category,
})
})
When('I create a task instance with title {string} category {string}', async function (this: PerausetWorld, title: string, category: string) {
await this.post('/tasks', {
title,
category,
})
})
When('I assign the task to user {string}', async function (this: PerausetWorld, email: string) {
const taskId = (this as any).taskId
// Look up userId from DB
let userId = this.createdUsers[email]
if (!userId) {
userId = getUserId(email)
this.createdUsers[email] = userId
}
await this.post(`/tasks/${taskId}/assign`, {
taskId,
userId,
})
})
When('I start the task', async function (this: PerausetWorld) {
const taskId = (this as any).taskId
await this.post(`/tasks/${taskId}/start`, {
taskId,
})
})
When('I complete the task', async function (this: PerausetWorld) {
const taskId = (this as any).taskId
await this.post(`/tasks/${taskId}/complete`, {
taskId,
})
})
When('I claim the task', async function (this: PerausetWorld) {
const taskId = (this as any).taskId
await this.post(`/tasks/${taskId}/claim`, {
taskId,
})
})
When('I mark the task as blocked with reason {string}', async function (this: PerausetWorld, reason: string) {
const taskId = (this as any).taskId
await this.post(`/tasks/${taskId}/block`, {
taskId,
reason,
})
})

View File

@@ -0,0 +1,37 @@
import { Given, When, Then } from '@cucumber/cucumber'
import { strict as assert } from 'assert'
import type { PerausetWorld } from '../support/world.js'
import { ensureUser } from '../support/db.js'
Given('a user {string} exists', function (this: PerausetWorld, email: string) {
this.createdUsers[email] = ensureUser(email, [])
})
Given('a user {string} exists with roles {string}', function (this: PerausetWorld, email: string, roles: string) {
const rolesArray = roles.split(',').map((r) => r.trim())
this.createdUsers[email] = ensureUser(email, rolesArray)
})
When('I get my own user profile', async function (this: PerausetWorld) {
const userId = await this.getSessionUserId()
await this.get(`/users/${userId}`)
})
When('I update my profile with displayName {string}', async function (this: PerausetWorld, displayName: string) {
const userId = await this.getSessionUserId()
await this.put(`/users/${userId}/profile`, { displayName })
})
Then('the response body should be a list with field {string}', function (this: PerausetWorld, field: string) {
assert.ok(this.lastBody, 'No response body')
assert.ok(Array.isArray(this.lastBody[field]), `Expected body.${field} to be an array, got: ${JSON.stringify(this.lastBody[field])}`)
})
Then('the response body field {string} should be {string}', function (this: PerausetWorld, field: string, value: string) {
assert.ok(this.lastBody, 'No response body')
assert.equal(
this.lastBody[field],
value,
`Expected body.${field} to be "${value}", got: "${this.lastBody[field]}"`
)
})

View File

@@ -0,0 +1,58 @@
function buildHeaders(cookies?: string): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
}
if (cookies) {
headers['Cookie'] = cookies
}
return headers
}
export async function apiGet(
url: string,
path: string,
cookies?: string
): Promise<Response> {
return fetch(`${url}${path}`, {
method: 'GET',
headers: buildHeaders(cookies),
})
}
export async function apiPost(
url: string,
path: string,
body: any,
cookies?: string
): Promise<Response> {
return fetch(`${url}${path}`, {
method: 'POST',
headers: buildHeaders(cookies),
body: JSON.stringify(body),
})
}
export async function apiPut(
url: string,
path: string,
body: any,
cookies?: string
): Promise<Response> {
return fetch(`${url}${path}`, {
method: 'PUT',
headers: buildHeaders(cookies),
body: JSON.stringify(body),
})
}
export async function apiDelete(
url: string,
path: string,
cookies?: string
): Promise<Response> {
return fetch(`${url}${path}`, {
method: 'DELETE',
headers: buildHeaders(cookies),
})
}

View File

@@ -0,0 +1,22 @@
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
// `pikku db reset` only operates on files inside .pikku-runtime/, so the e2e db
// lives there (not e2e/.tmp).
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
// Browser e2e config.
//
// The harness (browser-hooks.ts) starts both servers itself:
// - backend → backend/bin/start-e2e.ts (SQLite) on backendPort
// - frontend → apps/app vite dev server on 6001, configured to proxy /api to
// the backend (VITE_BACKEND_URL) and to treat its own origin as the API
// base (VITE_API_URL=<appUrl>/api) so auth + RPC stay same-origin.
export const browserConfig = {
appUrl: process.env.APP_URL || 'http://localhost:6001',
backendUrl: process.env.E2E_BACKEND_URL || 'http://localhost:6002',
/** Isolated SQLite file the e2e backend serves. */
dbFile: process.env.E2E_DB_FILE || resolve(repoRoot, '.pikku-runtime/browser-e2e.db'),
timeout: Number(process.env.E2E_TIMEOUT ?? 60_000),
headed: process.env.HEADED === '1',
}

View File

@@ -0,0 +1,136 @@
import { BeforeAll, AfterAll, Before, After, setDefaultTimeout } from '@cucumber/cucumber'
import { spawn, spawnSync, type ChildProcess } from 'child_process'
import { rmSync, mkdirSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { browserConfig } from './browser-config.js'
import type { BrowserWorld } from './browser-world.js'
setDefaultTimeout(browserConfig.timeout)
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '../../..')
// Provision the isolated e2e db with `pikku db reset` (migrations + seed);
// PIKKU_SQLITE_DB points createConfig at this file.
function provisionDb(dbFile: string): void {
const res = spawnSync('npx', ['pikku', 'db', 'reset'], {
cwd: repoRoot,
env: { ...process.env, PIKKU_SQLITE_DB: dbFile },
encoding: 'utf8',
})
if (res.status !== 0) {
throw new Error(
`pikku db reset failed (${res.status}):\n${res.stdout ?? ''}\n${res.stderr ?? ''}`
)
}
}
let backendProcess: ChildProcess | undefined
let frontendProcess: ChildProcess | undefined
async function waitForServer(url: string, label: string, timeoutMs = 90_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const res = await fetch(url)
if (res.ok || res.status < 500) {
console.log(`[${label}] ready on ${url}`)
return
}
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500))
}
throw new Error(`[browser-e2e] ${label} not ready within ${timeoutMs / 1000}s on ${url}`)
}
function startProcess(cmd: string, cwd: string, env: Record<string, string>, label: string) {
const p = spawn(cmd, { cwd, env, stdio: 'pipe', shell: true, detached: true })
p.stderr?.on('data', (d: Buffer) => process.stderr.write(`[${label}] ${d}`))
p.stdout?.on('data', (d: Buffer) => process.stdout.write(`[${label}] ${d}`))
return p
}
function stopProcess(p: ChildProcess | undefined) {
if (!p?.pid) return
try {
process.kill(-p.pid, 'SIGTERM')
} catch {
try {
p.kill('SIGTERM')
} catch {
// already dead
}
}
}
BeforeAll(async function () {
const backendPort = new URL(browserConfig.backendUrl).port || '6002'
// Fresh SQLite file per run, provisioned by `pikku db reset`.
for (const suffix of ['', '-wal', '-shm']) {
rmSync(`${browserConfig.dbFile}${suffix}`, { force: true })
}
mkdirSync(dirname(browserConfig.dbFile), { recursive: true })
provisionDb(browserConfig.dbFile)
console.log('[browser-e2e] starting backend…')
backendProcess = startProcess(
'npx tsx backend/bin/start-e2e.ts',
repoRoot,
{
...(process.env as Record<string, string>),
PORT: backendPort,
E2E_DB_FILE: browserConfig.dbFile,
// 32+ chars keeps better-auth quiet; BETTER_AUTH_URL = the app origin so
// better-auth trusts the browser's Origin (requests arrive via the vite
// same-origin proxy).
BETTER_AUTH_SECRET: 'e2e-browser-secret-32-characters-long',
BETTER_AUTH_URL: browserConfig.appUrl,
AUTH_SECRET: 'e2e-browser-secret-32-characters-long',
},
'backend'
)
await waitForServer(`${browserConfig.backendUrl}/health-check`, 'backend')
// Admin (admin@perauset.org / "test") comes from db/sqlite-seed.sql via
// `pikku db reset`.
console.log('[browser-e2e] starting frontend…')
frontendProcess = startProcess(
'yarn dev',
resolve(repoRoot, 'apps/app'),
{
...(process.env as Record<string, string>),
// Absolute, app-origin API base: SSR-safe and keeps browser calls
// same-origin (no CORS). The vite proxy maps /api/auth → backend/api/auth
// and /api/* → backend root.
VITE_API_URL: `${browserConfig.appUrl}/api`,
VITE_BACKEND_URL: browserConfig.backendUrl,
},
'frontend'
)
await waitForServer(`${browserConfig.appUrl}/login`, 'frontend')
})
AfterAll(async function () {
stopProcess(backendProcess)
stopProcess(frontendProcess)
backendProcess = undefined
frontendProcess = undefined
})
Before(async function (this: BrowserWorld) {
await this.openBrowser()
})
After(async function (this: BrowserWorld, scenario) {
if (scenario.result?.status === 'FAILED' && this.page) {
const safe = scenario.pickle.name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()
await this.page
.screenshot({ path: `/tmp/e2e-fail-${safe}.png`, fullPage: true })
.catch(() => {})
}
await this.closeBrowser()
})

View File

@@ -0,0 +1,147 @@
import { World, setWorldConstructor, type IWorldOptions } from '@cucumber/cucumber'
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
import { expect } from '@playwright/test'
import { browserConfig } from './browser-config.js'
export class BrowserWorld extends World {
browser: Browser | null = null
context: BrowserContext | null = null
page: Page | null = null
constructor(options: IWorldOptions) {
super(options)
}
async openBrowser(): Promise<void> {
this.browser = await chromium.launch({
headless: !browserConfig.headed,
})
this.context = await this.browser.newContext({
viewport: { width: 1280, height: 720 },
})
this.page = await this.context.newPage()
this.page.setDefaultTimeout(browserConfig.timeout)
}
async closeBrowser(): Promise<void> {
if (this.page) {
await this.page.close().catch(() => {})
this.page = null
}
if (this.context) {
await this.context.close().catch(() => {})
this.context = null
}
if (this.browser) {
await this.browser.close().catch(() => {})
this.browser = null
}
}
async login(email: string): Promise<void> {
const page = this.getPage()
await page.goto(`${browserConfig.appUrl}/login`, { waitUntil: 'networkidle' })
// The login page pre-fills demo credentials; overwrite with the test user.
const emailInput = page.locator('input[type="email"]')
await emailInput.fill(email)
const passwordInput = page.locator('input[type="password"]')
await passwordInput.fill('test')
await page.getByRole('button', { name: /sign in/i }).click()
// Wait for redirect away from /login, then for the app shell heading.
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 60_000 })
await page.getByRole('heading', { name: 'Dashboard' }).first().waitFor({ state: 'visible', timeout: 30_000 })
}
async navigateTo(path: string): Promise<void> {
const page = this.getPage()
await page.goto(`${browserConfig.appUrl}${path}`, { waitUntil: 'networkidle', timeout: 30_000 })
// Wait for client components to hydrate
await page.waitForTimeout(1000)
}
async clickLink(name: string): Promise<void> {
const page = this.getPage()
await page.getByRole('link', { name }).click()
// Small wait for navigation
await page.waitForLoadState('networkidle')
}
async clickButton(name: string): Promise<void> {
const page = this.getPage()
const btn = page.getByRole('button', { name, exact: true })
const count = await btn.count()
if (count > 1) {
// Multiple buttons — close any open dropdowns first, then click the last one (in drawer)
await page.keyboard.press('Escape')
await page.waitForTimeout(300)
// Re-query after Escape (drawer might have closed)
const btn2 = page.getByRole('button', { name, exact: true })
const count2 = await btn2.count()
const target = count2 > 1 ? btn2.nth(count2 - 1) : btn2
await target.scrollIntoViewIfNeeded()
await target.click({ timeout: 10_000 })
} else {
await btn.scrollIntoViewIfNeeded()
await btn.click({ timeout: 10_000 })
}
await page.waitForTimeout(500)
}
async fillField(label: string, value: string): Promise<void> {
const page = this.getPage()
const scope =
(await page.getByRole('dialog').count()) > 0
? page.getByRole('dialog')
: page
const control = page.locator('input:not([type="hidden"]), textarea')
let input = scope.getByLabel(label, { exact: true }).and(control)
if ((await input.count()) === 0) {
input = scope.getByLabel(label).and(control)
}
const field = input.first()
await field.clear()
await field.fill(value)
}
async selectOption(label: string, value: string): Promise<void> {
const page = this.getPage()
const scope =
(await page.getByRole('dialog').count()) > 0
? page.getByRole('dialog')
: page
const control = page.locator('input, [role="combobox"], select')
let input = scope.getByLabel(label, { exact: true }).and(control)
if ((await input.count()) === 0) {
input = scope.getByLabel(label).and(control)
}
await input.first().click()
await page.getByRole('option', { name: value, exact: true }).first().click()
}
async expectText(text: string): Promise<void> {
const page = this.getPage()
await expect(page.getByText(text, { exact: false }).first()).toBeVisible({ timeout: 10_000 })
}
async expectNoText(text: string): Promise<void> {
const page = this.getPage()
await expect(page.getByText(text, { exact: false })).not.toBeVisible({ timeout: 5_000 })
}
async expectHeading(text: string): Promise<void> {
const page = this.getPage()
await expect(page.getByRole('heading', { name: text }).first()).toBeVisible({ timeout: 10_000 })
}
getPage(): Page {
if (!this.page) {
throw new Error('Browser not open — did the Before hook run?')
}
return this.page
}
}
setWorldConstructor(BrowserWorld)

View File

@@ -0,0 +1,19 @@
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
export interface TestConfig {
apiUrl: string
/** Path to the SQLite file the e2e backend serves and the steps seed into. */
dbFile: string
timeout: number
}
// `pikku db reset` only operates on files inside .pikku-runtime/, so the e2e db
// lives there (not e2e/.tmp).
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
export const config: TestConfig = {
apiUrl: process.env.API_URL || 'http://localhost:6099',
dbFile: process.env.E2E_DB_FILE || resolve(repoRoot, '.pikku-runtime/e2e-api.db'),
timeout: 30_000,
}

66
e2e/tests/support/db.ts Normal file
View File

@@ -0,0 +1,66 @@
import { randomUUID } from 'node:crypto'
import Database from 'better-sqlite3'
import { config } from './config.js'
// better-auth scrypt hash for the password "test" (salt:hash, self-contained).
// Every e2e user gets this credential so `I login as "<email>"` works.
export const TEST_PASSWORD = 'test'
const TEST_PASSWORD_HASH =
'0b27de3eeeab546bf7bce4a94511417b:9f68ffc9cd63e21104cd033699ac9c695ddcb4cb982ea30def1b12751de1012aa12de46cd17c1e4c79276299dc8498e7d177ee59156ab7833d1d026e9d9647b6'
/** Open a short-lived connection to the e2e SQLite file the backend serves. */
export function openDb(): Database.Database {
const db = new Database(config.dbFile)
db.pragma('foreign_keys = ON')
db.pragma('busy_timeout = 5000')
return db
}
/**
* Ensure a better-auth user exists with the given roles and a credential
* account (password "test"). Idempotent — updates roles if the user exists.
* Returns the user id.
*/
export function ensureUser(email: string, roles: string[] = []): string {
const db = openDb()
try {
const existing = db
.prepare('SELECT id FROM user WHERE email = ?')
.get(email) as { id: string } | undefined
if (existing) {
db.prepare('UPDATE user SET member_roles = ? WHERE id = ?').run(
JSON.stringify(roles),
existing.id
)
return existing.id
}
const id = randomUUID()
db.prepare(
`INSERT INTO user (id, email, name, display_name, email_verified, member_roles)
VALUES (?, ?, ?, ?, 1, ?)`
).run(id, email, email, email, JSON.stringify(roles))
db.prepare(
`INSERT INTO account (id, account_id, provider_id, user_id, password)
VALUES (?, ?, 'credential', ?, ?)`
).run(randomUUID(), id, id, TEST_PASSWORD_HASH)
return id
} finally {
db.close()
}
}
/** Look up a user id by email. Throws if not found. */
export function getUserId(email: string): string {
const db = openDb()
try {
const row = db
.prepare('SELECT id FROM user WHERE email = ?')
.get(email) as { id: string } | undefined
if (!row) throw new Error(`User ${email} not found in e2e DB`)
return row.id
} finally {
db.close()
}
}

View File

@@ -0,0 +1,95 @@
import { BeforeAll, AfterAll, setDefaultTimeout } from '@cucumber/cucumber'
import { spawn, spawnSync, type ChildProcess } from 'child_process'
import { mkdirSync, rmSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { config } from './config.js'
setDefaultTimeout(config.timeout)
let serverProcess: ChildProcess | undefined
// Provision the isolated e2e db with the real tooling: `pikku db reset` applies
// every migration in db/sqlite/ and the db/sqlite-seed.sql seed. PIKKU_SQLITE_DB
// points createConfig at this file (see packages/functions/src/config.ts).
function provisionDb(projectDir: string, dbFile: string): void {
const res = spawnSync('npx', ['pikku', 'db', 'reset'], {
cwd: projectDir,
env: { ...process.env, PIKKU_SQLITE_DB: dbFile },
encoding: 'utf8',
})
if (res.status !== 0) {
throw new Error(
`pikku db reset failed (${res.status}):\n${res.stdout ?? ''}\n${res.stderr ?? ''}`
)
}
}
async function waitForServer(url: string, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const res = await fetch(url)
if (res.ok || res.status < 500) {
console.log(`[backend] ready on ${url}`)
return
}
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500))
}
throw new Error(`Backend did not start within ${timeoutMs / 1000}s on ${url}`)
}
BeforeAll(async function () {
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectDir = resolve(__dirname, '../../..')
const port = new URL(config.apiUrl).port
// Fresh SQLite file per run, provisioned by `pikku db reset`.
for (const suffix of ['', '-wal', '-shm']) {
rmSync(`${config.dbFile}${suffix}`, { force: true })
}
mkdirSync(dirname(config.dbFile), { recursive: true })
provisionDb(projectDir, config.dbFile)
const serverEnv: Record<string, string> = {
...(process.env as Record<string, string>),
PORT: port,
E2E_DB_FILE: config.dbFile,
BETTER_AUTH_SECRET: 'e2e-test-secret',
AUTH_SECRET: 'e2e-test-secret',
}
// Start the isolated SQLite backend.
serverProcess = spawn('npx', ['tsx', 'backend/bin/start-e2e.ts'], {
cwd: projectDir,
env: serverEnv,
stdio: 'pipe',
detached: true,
})
serverProcess.stderr?.on('data', (d: Buffer) =>
process.stderr.write(`[backend] ${d}`)
)
serverProcess.stdout?.on('data', (d: Buffer) =>
process.stdout.write(`[backend] ${d}`)
)
await waitForServer(`${config.apiUrl}/health-check`)
// The admin (admin@perauset.org / "test") comes from db/sqlite-seed.sql via
// `pikku db reset`. Per-scenario users are created at runtime by the
// `a user ... exists` steps (ensureUser).
})
AfterAll(async function () {
if (serverProcess?.pid) {
try {
process.kill(-serverProcess.pid, 'SIGTERM')
} catch {
// process may already be dead
}
serverProcess = undefined
}
})

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

@@ -0,0 +1,124 @@
import { World, setWorldConstructor, type IWorldOptions } from '@cucumber/cucumber'
import { strict as assert } from 'assert'
import { apiGet, apiPost, apiPut, apiDelete } from './api-client.js'
import { config } from './config.js'
import { getUserId, TEST_PASSWORD } from './db.js'
export class PerausetWorld extends World {
lastResponse: Response | null = null
lastBody: any = null
cookies: string = ''
currentUserId: string = ''
currentEmail: string = ''
createdUsers: Record<string, string> = {}
lastAssignmentId: string = ''
constructor(options: IWorldOptions) {
super(options)
}
/**
* Authenticate as the given user via better-auth's email/password sign-in.
* POST /api/auth/sign-in/email with JSON {email, password} and capture the
* session cookie from the response.
*/
async login(email: string): Promise<void> {
const res = await fetch(`${config.apiUrl}/api/auth/sign-in/email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
// better-auth requires a trusted Origin for state-changing auth calls.
Origin: config.apiUrl,
},
body: JSON.stringify({ email, password: TEST_PASSWORD }),
redirect: 'manual',
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Login failed for ${email}: ${res.status} ${text}`)
}
this.cookies = this.extractSetCookies(res)
this.currentEmail = email
}
/**
* Look up the current user's id from the SQLite DB using the stored email.
*/
async getSessionUserId(): Promise<string> {
assert.ok(this.currentEmail, 'No current email set -- did you call login() first?')
return getUserId(this.currentEmail)
}
async get(path: string): Promise<void> {
this.lastResponse = await apiGet(config.apiUrl, path, this.cookies)
this.lastBody = await this.lastResponse.clone().json().catch(() => null)
this.captureCookies()
}
async post(path: string, body: any): Promise<void> {
this.lastResponse = await apiPost(config.apiUrl, path, body, this.cookies)
this.lastBody = await this.lastResponse.clone().json().catch(() => null)
this.captureCookies()
}
async put(path: string, body: any): Promise<void> {
this.lastResponse = await apiPut(config.apiUrl, path, body, this.cookies)
this.lastBody = await this.lastResponse.clone().json().catch(() => null)
this.captureCookies()
}
async delete(path: string): Promise<void> {
this.lastResponse = await apiDelete(config.apiUrl, path, this.cookies)
this.lastBody = await this.lastResponse.clone().json().catch(() => null)
this.captureCookies()
}
expectStatus(status: number): void {
assert.ok(this.lastResponse, 'No response received')
assert.equal(
this.lastResponse.status,
status,
`Expected status ${status} but got ${this.lastResponse.status}. Body: ${JSON.stringify(this.lastBody)}`
)
}
private captureCookies(): void {
const setCookie = this.lastResponse?.headers.get('set-cookie')
if (setCookie) {
this.cookies = this.mergeCookies(this.cookies, setCookie)
}
}
private extractSetCookies(res: Response): string {
// getSetCookie() returns individual set-cookie values as an array
const raw = (res.headers as any).getSetCookie?.() as string[] | undefined
if (raw && raw.length > 0) {
return raw.map((c: string) => c.split(';')[0]).join('; ')
}
// Fallback: use get('set-cookie') which may be comma-joined
const header = res.headers.get('set-cookie')
if (!header) return ''
return header
.split(/,(?=\s*\w+=)/)
.map((c: string) => c.split(';')[0].trim())
.join('; ')
}
private mergeCookies(existing: string, incoming: string): string {
if (!existing) return incoming
if (!incoming) return existing
const map = new Map<string, string>()
for (const part of existing.split(';').map((s) => s.trim()).filter(Boolean)) {
const [name] = part.split('=', 1)
map.set(name, part)
}
for (const part of incoming.split(';').map((s) => s.trim()).filter(Boolean)) {
const [name] = part.split('=', 1)
map.set(name, part)
}
return [...map.values()].join('; ')
}
}
setWorldConstructor(PerausetWorld)