chore: perauset customer project
This commit is contained in:
7
e2e/tests/cucumber-browser.mjs
Normal file
7
e2e/tests/cucumber-browser.mjs
Normal 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
6
e2e/tests/cucumber.mjs
Normal 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'],
|
||||
}
|
||||
14
e2e/tests/features/audit-log.feature
Normal file
14
e2e/tests/features/audit-log.feature
Normal 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
|
||||
10
e2e/tests/features/auth.feature
Normal file
10
e2e/tests/features/auth.feature
Normal 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
|
||||
122
e2e/tests/features/boats.feature
Normal file
122
e2e/tests/features/boats.feature
Normal 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
|
||||
14
e2e/tests/features/browser/finance.feature
Normal file
14
e2e/tests/features/browser/finance.feature
Normal 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"
|
||||
16
e2e/tests/features/browser/inventory.feature
Normal file
16
e2e/tests/features/browser/inventory.feature
Normal 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"
|
||||
15
e2e/tests/features/browser/login.feature
Normal file
15
e2e/tests/features/browser/login.feature
Normal 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"
|
||||
63
e2e/tests/features/browser/navigation.feature
Normal file
63
e2e/tests/features/browser/navigation.feature
Normal 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"
|
||||
15
e2e/tests/features/browser/rooms.feature
Normal file
15
e2e/tests/features/browser/rooms.feature
Normal 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"
|
||||
15
e2e/tests/features/browser/stays.feature
Normal file
15
e2e/tests/features/browser/stays.feature
Normal 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."
|
||||
58
e2e/tests/features/finance.feature
Normal file
58
e2e/tests/features/finance.feature
Normal 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
|
||||
6
e2e/tests/features/health-check.feature
Normal file
6
e2e/tests/features/health-check.feature
Normal 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
|
||||
77
e2e/tests/features/inventory.feature
Normal file
77
e2e/tests/features/inventory.feature
Normal 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
|
||||
29
e2e/tests/features/kitchen.feature
Normal file
29
e2e/tests/features/kitchen.feature
Normal 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
|
||||
14
e2e/tests/features/notifications.feature
Normal file
14
e2e/tests/features/notifications.feature
Normal 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
|
||||
68
e2e/tests/features/retreats.feature
Normal file
68
e2e/tests/features/retreats.feature
Normal 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
|
||||
20
e2e/tests/features/roles.feature
Normal file
20
e2e/tests/features/roles.feature
Normal 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
|
||||
105
e2e/tests/features/rooms.feature
Normal file
105
e2e/tests/features/rooms.feature
Normal 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
|
||||
102
e2e/tests/features/stays.feature
Normal file
102
e2e/tests/features/stays.feature
Normal 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
|
||||
87
e2e/tests/features/tasks.feature
Normal file
87
e2e/tests/features/tasks.feature
Normal 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
|
||||
24
e2e/tests/features/users.feature
Normal file
24
e2e/tests/features/users.feature
Normal 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
|
||||
12
e2e/tests/steps/audit-log.steps.ts
Normal file
12
e2e/tests/steps/audit-log.steps.ts
Normal 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))}`)
|
||||
})
|
||||
6
e2e/tests/steps/auth.steps.ts
Normal file
6
e2e/tests/steps/auth.steps.ts
Normal 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)
|
||||
})
|
||||
64
e2e/tests/steps/boats.steps.ts
Normal file
64
e2e/tests/steps/boats.steps.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
141
e2e/tests/steps/browser/common.steps.ts
Normal file
141
e2e/tests/steps/browser/common.steps.ts
Normal 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 })
|
||||
}
|
||||
)
|
||||
38
e2e/tests/steps/finance.steps.ts
Normal file
38
e2e/tests/steps/finance.steps.ts
Normal 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}`)
|
||||
})
|
||||
10
e2e/tests/steps/health-check.steps.ts
Normal file
10
e2e/tests/steps/health-check.steps.ts
Normal 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)
|
||||
})
|
||||
49
e2e/tests/steps/inventory.steps.ts
Normal file
49
e2e/tests/steps/inventory.steps.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
22
e2e/tests/steps/kitchen.steps.ts
Normal file
22
e2e/tests/steps/kitchen.steps.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
29
e2e/tests/steps/notifications.steps.ts
Normal file
29
e2e/tests/steps/notifications.steps.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
59
e2e/tests/steps/retreats.steps.ts
Normal file
59
e2e/tests/steps/retreats.steps.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
22
e2e/tests/steps/roles.steps.ts
Normal file
22
e2e/tests/steps/roles.steps.ts
Normal 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)}`)
|
||||
})
|
||||
51
e2e/tests/steps/rooms.steps.ts
Normal file
51
e2e/tests/steps/rooms.steps.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
19
e2e/tests/steps/shared.steps.ts
Normal file
19
e2e/tests/steps/shared.steps.ts
Normal 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])}`)
|
||||
})
|
||||
57
e2e/tests/steps/stays.steps.ts
Normal file
57
e2e/tests/steps/stays.steps.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
63
e2e/tests/steps/tasks.steps.ts
Normal file
63
e2e/tests/steps/tasks.steps.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
37
e2e/tests/steps/users.steps.ts
Normal file
37
e2e/tests/steps/users.steps.ts
Normal 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]}"`
|
||||
)
|
||||
})
|
||||
58
e2e/tests/support/api-client.ts
Normal file
58
e2e/tests/support/api-client.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
22
e2e/tests/support/browser-config.ts
Normal file
22
e2e/tests/support/browser-config.ts
Normal 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',
|
||||
}
|
||||
136
e2e/tests/support/browser-hooks.ts
Normal file
136
e2e/tests/support/browser-hooks.ts
Normal 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()
|
||||
})
|
||||
147
e2e/tests/support/browser-world.ts
Normal file
147
e2e/tests/support/browser-world.ts
Normal 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)
|
||||
19
e2e/tests/support/config.ts
Normal file
19
e2e/tests/support/config.ts
Normal 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
66
e2e/tests/support/db.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
95
e2e/tests/support/hooks.ts
Normal file
95
e2e/tests/support/hooks.ts
Normal 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
124
e2e/tests/support/world.ts
Normal 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)
|
||||
Reference in New Issue
Block a user