diff --git a/.claude/rules/backend/account-deletion.md b/.claude/rules/backend/account-deletion.md
index 626a4d0f9d..7f017080af 100644
--- a/.claude/rules/backend/account-deletion.md
+++ b/.claude/rules/backend/account-deletion.md
@@ -13,3 +13,4 @@ paths:
- Never delete `organizer_configurations` or `account_configuration` rows (shared plan rows) or `ticket_lookup_tokens` (buyer-email scoped across accounts). `stripe_customers` has no account foreign key; match it by Stripe account id.
- Users can belong to several accounts: a sole-account user is scrubbed and soft-deleted, and their `user_identities` rows are deleted so they can't sign back in with single sign-on; a shared user only loses the `account_users` row.
- An account's SSO connection, its domains and the `user_identities` tagged with the connection are deleted before `accounts` (`SsoConnectionAnonymizer` mirrors this).
+- Anonymisation nulls the `organizer_settings` free text (`homepage_sections`, `website_url`, `location_details`, `social_media_handles`, `seo_*`). Event `homepage_sections` follow the event description retention rule and stay on the soft-deleted event.
diff --git a/.claude/rules/backend/sso.md b/.claude/rules/backend/sso.md
index 59f2a421f3..bf0c1396a5 100644
--- a/.claude/rules/backend/sso.md
+++ b/.claude/rules/backend/sso.md
@@ -10,7 +10,7 @@ paths:
# Single sign-on (OIDC)
-- One generic OIDC client (`Services/Domain/Auth/Sso`) serves Google, Microsoft and the operator's `SSO_OIDC_*` provider; no Socialite. Providers exist only when their client id and secret are set.
+- One generic OIDC client (`Services/Domain/Auth/Sso`) serves Google, Microsoft and the operator's `SSO_OIDC_*` provider; no Socialite. Providers exist, and their buttons show, only when their client id and secret are set; there is no separate visibility switch.
- Identities are keyed by (`issuer`, `subject`) in `user_identities`; Microsoft uses the `tid`-substituted issuer and `oid`, never `sub`, `email` or `upn`. Never write the provider email back to `users.email`.
- An email links an existing user only when the provider's email is trusted (`SsoIdentityClaimsMapper`) AND the user was verified in SaaS mode or password sign-in is disabled. Superadmins, untrusted emails and ambiguous matches get `SSO_LINK_REQUIRES_LOGIN` before any membership check, so responses can't reveal account state; never create a signup ticket for an email that already has a user. The first link by email while password sign-in is off clears the old password, trusted devices and sessions.
- Every step after `start` is bound to the browser: the state, the 2FA challenge, the account-choice ticket and the signup ticket all carry the binding hash (`SsoTicketService`, one cache entry per `SsoTicketType`). Keep that when adding a step.
diff --git a/.claude/rules/backend/team-access.md b/.claude/rules/backend/team-access.md
index 2865318c7f..5b8411883b 100644
--- a/.claude/rules/backend/team-access.md
+++ b/.claude/rules/backend/team-access.md
@@ -8,6 +8,8 @@ paths:
- "backend/app/DomainObjects/Enums/Permission.php"
- "backend/app/DomainObjects/Enums/AccountUserAccessScope.php"
- "backend/tests/Feature/Http/TeamAccess/**"
+ - "backend/app/Http/Request/EventSettings/**"
+ - "frontend/src/components/routes/event/Settings/Sections/PaymentSettings/**"
- "frontend/src/hooks/useAccess.ts"
- "frontend/src/components/common/TeamAccess*/**"
- "frontend/src/components/common/WriteAccess/**"
@@ -21,6 +23,7 @@ paths:
- A restricted member with no grants has no access, never full access. An event grant also gives read-only access to its organizer (`isOrganizerReadAuthorized`); an organizer grant covers its future events too.
- Treat any value that unlocks a public page as a credential. Staff responses return `orders.short_id` only to members with EDIT + VIEW_FINANCIALS; check-in list links are shareable with door staff on purpose.
- Deliberate, don't "fix": VIEW_FINANCIALS hides money in the dashboard but doesn't stop an EDIT member from configuring outbound channels (organizer email, email templates, analytics pixels) or running the door, which shows door sales.
+- Free-text fields that tell buyers where to send money are payment settings. Changing an event's `offline_payment_instructions` needs MANAGE_PAYMENTS (Admins only by default) and a session: `UpdateEventSettingsRequest::after()` returns a 422 on the field for an API key or a member without it, on both PUT and PATCH. Re-sending the saved value (or one that purifies to it) always passes, so round-trips keep working; a PUT that omits it counts as clearing it. The payment settings form shows the instructions read-only to those members and re-sends the saved value. Same pattern as GTM containers in `PartialUpdateOrganizerSettingsRequest`.
- Public "same account" checks (draft previews, test orders, check-in staff view) use `AccountUserAccessService::currentMemberCanAccessEvent/Organizer`, never a raw account id comparison.
- `AccountUserAccessService` caches access per request keyed by `AuthUserService::getAuthenticatedTokenKey()` (the JWT string, or `api-key:{id}` for API keys); never key it on decoded payload claims (resources call `isPermitted()` hundreds of times per list).
- Frontend: 403s carrying `ACCESS_RESTRICTED`, `PERMISSION_REQUIRED` or `READ_ONLY_ROLE` must never send the user to login.
diff --git a/.claude/rules/ee/enterprise-sso.md b/.claude/rules/ee/enterprise-sso.md
index 9601de3f58..cd609c2f17 100644
--- a/.claude/rules/ee/enterprise-sso.md
+++ b/.claude/rules/ee/enterprise-sso.md
@@ -8,12 +8,13 @@ paths:
# Enterprise single sign-on
- Only per-account connections are ee (`backend/ee/Sso`, `frontend/src/ee/sso`); social login and the operator's `SSO_OIDC_*` provider stay in core (`.claude/rules/backend/sso.md`). Core calls into ee directly (`LoginService`, `EnsureTokenNotRevoked`, `CreateAccountHandler`, `SsoProviderRegistry`).
-- An account's connection is a provider with key `connection-{id}` resolved by `SsoProviderRegistry::get`, so the phase 1 start/exchange flow serves it; it only accepts the LOGIN, REAUTH and TEST intents, and inactive connections only TEST.
+- An account's connection is a provider with key `connection-{id}` resolved by `SsoProviderRegistry::get`, so the phase 1 start/exchange flow serves it; it only accepts the LOGIN, LINK, REAUTH and TEST intents, and inactive connections only TEST.
- A connection binds tokens to itself through `aud` plus `tid` (Entra) or `hd` (Workspace). Its emails are trusted only when the provider marks them verified (`email_verified`, Entra `xms_edov`) AND the domain is verified and not suspended for that account. Never trust them through the phase 1 resolver; connection logins go through `EnterpriseSsoResolver`.
-- A connection never email-links an account owner or a superadmin, and its identities can't confirm identity (REAUTH) for users who belong to other accounts: otherwise an admin who re-points the connection at their own provider could take over other users.
+- A connection never email-links an account owner or a superadmin, and its identities can't confirm identity (REAUTH) for users who belong to other accounts: otherwise an admin who re-points the connection at their own provider could take over other users. A superadmin never links, signs in or confirms identity through a connection, even with an identity on it (`SsoConnectionLinkService::isSuperAdmin`): a tenant's identity provider must never authenticate platform staff. `SSO_LINK_REQUIRES_LOGIN` carries `context.email` (so the callback says to connect it in Security settings) only when the email is trusted and the single matching user could link this connection; otherwise the context is empty and the message says to use the password.
+- Members link a connection themselves from Security settings (LINK, after `IdentityConfirmationService`), the owner included, so the owner can sign in through it without the break-glass email. `SsoConnectionLinkService::assertCanLink` requires an ACTIVE membership of the connection's account and refuses superadmins, at start and again at exchange (`SSO_PROVIDER_NOT_CONFIGURED` otherwise); linking never grants membership or changes a role. `GET /users/me/sso-identities` lists the linkable connections in `meta.linkable_connections` (none for a superadmin).
- Every outbound request to a provider goes through `SsrfGuard` (https, public addresses only, pinned with `CURLOPT_RESOLVE`) unless `SSO_ALLOW_PRIVATE_ISSUERS` is set; never set it on multi-tenant installs.
- Domain status is derived from `verified_at` and `failing_since` (suspended seven days after the daily check starts failing); one account per verified domain, conflicts are resolved with `sso:verify-domain`.
-- Enforcement is `account_sso_connections.enforce_sso` plus the `account_users.sso_enforced` snapshot (set at enable and when someone joins, cleared only by an admin's exempt toggle). It's checked in `LoginService::completeLogin` and `EnsureTokenNotRevoked`; tokens must carry the account's `sso_connection_id`, and a token issued through a connection dies when that connection is turned off or deleted. A member the connection can't sign in (owner of any account or superadmin, with no identity on it) is not enforced, so they keep their password; the preview reports them as `sso_available: false`. A verified domain can't be removed while enforcing. The owner is never enforced: their password sign-in always asks for 2FA, emails every admin (the owner gets their own copy), and neither they nor support can turn their 2FA off while enforcing.
+- Enforcement is `account_sso_connections.enforce_sso` plus the `account_users.sso_enforced` snapshot (set at enable and when someone joins, cleared only by an admin's exempt toggle). It's checked in `LoginService::beginLogin` (members only, before a 2FA challenge is issued), `LoginService::completeLogin` and `EnsureTokenNotRevoked`; tokens must carry the account's `sso_connection_id`, and a token issued through a connection dies when that connection is turned off or deleted. `usersWhoCannotUseConnection` is the one answer to who the connection can't sign in: every superadmin, and an owner of any account without an identity on it (for Workspace, a shared Google identity counts only when its email is on the hosted domain). Those members still get the snapshot but are not enforced: enabling or requiring them doesn't revoke their sessions or API keys, they keep their password until they link it, the preview reports `sso_available: false` and the Team page shows `PASSWORD`. A verified domain can't be removed while enforcing. The owner is never enforced: their password sign-in always asks for 2FA, emails every admin (the owner gets their own copy), and neither they nor support can turn their 2FA off while enforcing.
- Forgot-password silently sends nothing when `isPasswordResetAllowed` is false; `/users/me` exposes the same answer as `password_reset_allowed` so Security settings doesn't offer a password link that never arrives.
- Domain capture: while a connection enforces, nobody can sign up with an email on its usable domains. `SsoEnforcementService::assertSignupAllowed` throws `SsoRequiredException` from `CreateAccountHandler` (password and social signup) and before a social signup ticket is issued; the frontend sends them to SSO discovery. Invitations to other accounts are not captured.
- Tokens issued through a connection use its session length and can't be refreshed (`SSO_SESSION_EXPIRED`); the frontend re-authenticates with `prompt=none`.
diff --git a/.claude/rules/features/lineup.md b/.claude/rules/features/lineup.md
index 55c3069094..834082cc43 100644
--- a/.claude/rules/features/lineup.md
+++ b/.claude/rules/features/lineup.md
@@ -15,4 +15,5 @@ paths:
- People and stages match by `nameKey` on the frontend and `ImportLineupHandler::key` on the backend; keep the two identical or the preview lies.
- The import preview mirrors every server validation limit so one bad cell never fails the whole import. Change both sides together.
- `ImportLineupHandler` writes through repositories in bulk; don't route it through the per-row upsert handlers (far too slow for 1,000 slots).
-- Per-date lineups for recurring events are derived from slot times (`lineupForOccurrence`), never stored.
+- Per-date lineups for recurring events are derived from slot times (`lineupForOccurrence`), never stored. When the Schedule section is hidden the public payload has no slots, so on recurring events only each entry carries `schedule_days` (dates only, computed on read) and `lineupForOccurrence` filters on those. Single-date events never get `schedule_days`: a hidden schedule must not reveal which day each act plays.
+- Ticket-email lineup highlights (caps, headliner/featured fallback, preferred slots) are picked by `LineupHighlightSelector`, which the spam check reuses to label that content `ticket email …` and put it first in the prompt. Change the selection there, never in a copy.
diff --git a/.claude/rules/features/widget.md b/.claude/rules/features/widget.md
index 4022d22bd9..9cfc43edb1 100644
--- a/.claude/rules/features/widget.md
+++ b/.claude/rules/features/widget.md
@@ -20,7 +20,9 @@ paths:
- Attributes reach the iframe as PascalCase params (`data-hievents-continue-button-text` → `ContinueButtonText`). `resolveWidgetConfig` maps them onto the snake_case keys that saved widgets store. Host-page `aff`, `promo_code` and `utm_*` are forwarded as-is.
- Language: a `data-hievents-locale` attribute wins, then a saved widget's `locale` (the SSR loader sets `widgetLocale` on the request context; the client reads it from the hydrated loader data), then the visitor's language. Old snippets hardcode `data-hievents-locale="en"` with `data-hievents-widget-version="1.0"`; that pair is ignored.
- A saved widget's embed carries only what the host page needs (`HOST_KEYS` in `WidgetEditor/embedCode.ts`); the rest comes from `/public/widgets/{short_id}`, and attributes override it. The Button type's text, colours and radius are drawn by `widget.js` before anything loads, so they live in the embed code; the editor tells users to re-copy the code when a saved widget's embed attributes change. Orders store `widget_id` for stats (seat-picker orders too). Views count anonymous loads, deduped per IP and written in batches (`WidgetViewIncrementService`), never one UPDATE per load.
-- Widget routes report the height of `WidgetFrame`'s inner wrapper, never `document.scrollHeight`, so they can shrink. Dialogs inside auto-resized embeds are placed near the click and clamped to fit (`EmbeddedDialogOffset`); Mantine's default sizing uses `100dvh`, which is the whole content height in an auto-resized iframe.
+- Saving a widget looks up every newly sent product, category and date id on the event and returns 422 on a miss. Ids already stored on the widget that have since been deleted are dropped silently, so the widget can still be saved or renamed; when that empties a selection the widget shows all tickets (or any date), and the editor previews this before saving (`savedSettings`).
+- A missing event or organizer sets `notFound` in the loader data; widget components read it from `useWidgetRuntime()` and turn their queries off, so server and client render the same message.
+- Widget routes report the height of `WidgetFrame`'s inner wrapper, never `document.scrollHeight`, so they can shrink. Dialogs inside auto-resized embeds are placed near the click, inside the part of the iframe the visitor can see (measured with an `IntersectionObserver`), and `WidgetFrame` reports extra height while one is open so the iframe can grow (`EmbeddedDialogOffset` + `embeddedDialogPlacement.ts`); Mantine's default sizing uses `100dvh`, which is the whole content height in an auto-resized iframe.
- Widget routes render real content on the server, so any input that appears before hydration must stay disabled until `useIsHydrated()` (see `NumberSelector`), or the visitor's input is wiped.
- The editor preview loads the real widget route with `Preview=true` and pushes settings over `postMessage`; preview mode never creates orders.
- Widgets run on third-party pages: keep widget routes free of heavy imports, and let the SSR loaders prefetch everything the first paint needs.
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 6a6924301a..8003ebb5c9 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -26,6 +26,33 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
+ unit:
+ name: e2e unit specs
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: e2e/package-lock.json
+
+ - name: Install dependencies
+ working-directory: e2e
+ run: npm ci
+
+ - name: Run pure-logic specs
+ working-directory: e2e
+ run: npx playwright test --config tests '\.unit\.spec\.ts$' --forbid-only --reporter=github,list
+
plan:
runs-on: ubuntu-latest
outputs:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5473efb0a4..9e59fec888 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -4,14 +4,29 @@ on:
push:
branches: [main, develop, 'v[0-9]+.[0-9]+.[0-9]+-*']
paths:
+ - 'VERSION'
- 'backend/**'
- '.github/workflows/tests.yml'
pull_request:
paths:
+ - 'VERSION'
- 'backend/**'
- '.github/workflows/tests.yml'
jobs:
+ version:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Check backend/VERSION matches VERSION
+ run: |
+ if [ "$(cat VERSION)" != "$(cat backend/VERSION)" ]; then
+ echo "::error file=backend/VERSION::backend/VERSION ($(cat backend/VERSION)) does not match VERSION ($(cat VERSION))"
+ exit 1
+ fi
+
tests:
runs-on: ubuntu-latest
@@ -20,9 +35,6 @@ jobs:
php-versions: ['8.3', '8.4', '8.5']
services:
- # Postgres is started for the Feature suite. The Unit suite does not need
- # a live connection — it only reads DB_DATABASE for the _test guard check
- # in CreatesApplication — so it runs in parallel with Postgres warming up.
postgres:
image: postgres:15
env:
@@ -37,9 +49,6 @@ jobs:
--health-timeout 5s
--health-retries 5
- # Job-level env. .env.testing supplies the rest, but the DB host on a CI
- # runner is 127.0.0.1 (service container exposes its port on the runner),
- # not the docker network alias used locally — override here.
env:
DB_HOST: 127.0.0.1
DB_PORT: 5432
@@ -78,16 +87,9 @@ jobs:
run: cd backend && composer install --prefer-dist --no-progress --no-interaction
- name: Stage .env for testing
- # Laravel auto-loads .env.testing when APP_ENV=testing, but artisan
- # commands run outside that flow read .env directly. Copy .env.testing
- # to .env so both paths see the same config.
run: cp backend/.env.testing backend/.env
- name: Run Unit test suite
- # Pure unit tests — no DB connection, no migrations. The CreatesApplication
- # bootstrap detects the absence of DatabaseTransactions / RefreshDatabase
- # traits and skips migrate:fresh entirely. Runs in parallel with the
- # Postgres service container coming up.
run: cd backend && ./vendor/bin/phpunit --testsuite=Unit --no-coverage
- name: Wait for Postgres
@@ -102,7 +104,4 @@ jobs:
exit 1
- name: Run Feature test suite
- # Integration tests against the real PostgreSQL test database. The first
- # test that boots Laravel triggers migrate:fresh once per process via
- # CreatesApplication::ensureTestDatabaseIsMigrated.
run: cd backend && ./vendor/bin/phpunit --testsuite=Feature --no-coverage
diff --git a/VERSION b/VERSION
index edb49bc725..b87c4d4109 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.0.0-beta
+2.0.0-rc.3
diff --git a/backend/.env.example b/backend/.env.example
index 609a953d79..f217234ac5 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -14,6 +14,8 @@ APP_LICENCE_KEY=
APP_TRUSTED_PROXIES=*
# Optional. Only needed when APP_TRUSTED_PROXIES doesn't include the frontend server; set the same value on the frontend
APP_SSR_SHARED_SECRET=
+# Optional. API requests a minute per user, shared by the web app and all of that user's API keys
+# APP_API_RATE_LIMIT_PER_MINUTE=180
APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=1.5
APP_HOMEPAGE_VIEWS_UPDATE_BATCH_SIZE=8
APP_DISABLE_REGISTRATION=false
@@ -111,9 +113,10 @@ GOOGLE_MAPS_API_KEY=
# Only set this when the provider verifies every email address it issues
# SSO_OIDC_TRUST_EMAIL=false
# Turn off email and password sign-in for everyone except super admins
+# Users who have not linked SSO can only get in if the provider vouches for their email; for SSO_OIDC_* that needs
+# SSO_OIDC_TRUST_EMAIL=true. Preview how many users are affected before turning it on:
+# SSO_PASSWORD_LOGIN_DISABLED=true php artisan sso:status
# SSO_PASSWORD_LOGIN_DISABLED=false
-# Hide the sign-in buttons until launch; add ?sso=1 to the sign-in page URL to see them
-# SSO_BUTTONS_VISIBLE=true
# Let identity providers on private networks or plain http be used, for example an internal Keycloak.
# Never set this on an install where people you don't trust can manage an account's single sign-on.
# SSO_ALLOW_PRIVATE_ISSUERS=false
diff --git a/backend/VERSION b/backend/VERSION
index 68e06170e2..b87c4d4109 100644
--- a/backend/VERSION
+++ b/backend/VERSION
@@ -1 +1 @@
-2.0.0-alpha.1
\ No newline at end of file
+2.0.0-rc.3
diff --git a/backend/app/Console/Commands/SsoStatusCommand.php b/backend/app/Console/Commands/SsoStatusCommand.php
index db5e03d520..ee576e40a9 100644
--- a/backend/app/Console/Commands/SsoStatusCommand.php
+++ b/backend/app/Console/Commands/SsoStatusCommand.php
@@ -5,6 +5,7 @@
use HiEvents\DomainObjects\Enums\SsoProviderType;
use HiEvents\Exceptions\SsoException;
use HiEvents\Repository\Interfaces\UserRepositoryInterface;
+use HiEvents\Services\Domain\Auth\Sso\DTO\SsoProviderConfig;
use HiEvents\Services\Domain\Auth\Sso\OidcClient;
use HiEvents\Services\Domain\Auth\Sso\OidcDiscoveryClient;
use HiEvents\Services\Domain\Auth\Sso\SsoProviderRegistry;
@@ -51,19 +52,46 @@ public function handle(
}
if ($providerRegistry->isPasswordLoginDisabled()) {
- $this->warnAboutPasswordOnlyUsers($userRepository);
+ $this->warnAboutPasswordOnlyUsers($userRepository, $providers);
}
return $healthy ? self::SUCCESS : self::FAILURE;
}
- private function warnAboutPasswordOnlyUsers(UserRepositoryInterface $userRepository): void
+ /**
+ * @param SsoProviderConfig[] $providers
+ */
+ private function warnAboutPasswordOnlyUsers(UserRepositoryInterface $userRepository, array $providers): void
{
$count = $userRepository->countWithoutSingleSignOnExcludingSuperAdmins();
- if ($count > 0) {
- $this->newLine();
- $this->warn("$count users have not signed in with single sign-on yet. They can sign in once their provider email is verified and matches their account.");
+ if ($count === 0) {
+ return;
}
+
+ $this->newLine();
+ $this->warn("$count users have not signed in with single sign-on yet. With password sign-in disabled, a provider can only match them to their account by an email it vouches for:");
+
+ foreach ($providers as $provider) {
+ $this->line(' '.$this->emailMatchingRule($provider));
+ }
+
+ foreach ($providers as $provider) {
+ if ($provider->type === SsoProviderType::OIDC && ! $provider->trustEmail) {
+ $this->newLine();
+ $this->error("{$provider->name} can't match these users to their accounts because SSO_OIDC_TRUST_EMAIL is off, so they can't sign in with it. Set SSO_OIDC_TRUST_EMAIL=true if {$provider->name} verifies email addresses, or turn password sign-in back on until they have linked {$provider->name} in their profile.");
+ }
+ }
+ }
+
+ private function emailMatchingRule(SsoProviderConfig $provider): string
+ {
+ return match ($provider->type) {
+ SsoProviderType::GOOGLE => "{$provider->name}: verified Gmail addresses and Google Workspace accounts.",
+ SsoProviderType::MICROSOFT => "{$provider->name}: work and school accounts, once the app registration sends the optional \"xms_edov\" claim. Personal Microsoft accounts never match.",
+ SsoProviderType::OIDC => $provider->trustEmail
+ ? "{$provider->name}: emails it marks as verified, because SSO_OIDC_TRUST_EMAIL is on."
+ : "{$provider->name}: none, because SSO_OIDC_TRUST_EMAIL is off.",
+ };
}
}
diff --git a/backend/app/Console/Commands/SsoUnlinkIdentityCommand.php b/backend/app/Console/Commands/SsoUnlinkIdentityCommand.php
index f540a66498..bddee23939 100644
--- a/backend/app/Console/Commands/SsoUnlinkIdentityCommand.php
+++ b/backend/app/Console/Commands/SsoUnlinkIdentityCommand.php
@@ -38,7 +38,7 @@ public function handle(
$userIdentityService->deleteForUser($user->getId());
$sessionRevocationService->revokeAll($user->getId());
- $this->info('Single sign-on logins removed. The user can set a password with "Forgot password".');
+ $this->info('Single sign-on logins removed and the user is signed out everywhere. They can set a password with "Forgot password" unless password sign-in is turned off or every account they belong to requires single sign-on.');
return self::SUCCESS;
}
diff --git a/backend/app/DomainObjects/Enums/SsoIntent.php b/backend/app/DomainObjects/Enums/SsoIntent.php
index ee1829b08f..e6fdd326bf 100644
--- a/backend/app/DomainObjects/Enums/SsoIntent.php
+++ b/backend/app/DomainObjects/Enums/SsoIntent.php
@@ -18,6 +18,6 @@ enum SsoIntent: string
*/
public static function accountConnectionIntents(): array
{
- return [self::LOGIN, self::REAUTH, self::TEST];
+ return [self::LOGIN, self::LINK, self::REAUTH, self::TEST];
}
}
diff --git a/backend/app/DomainObjects/ProductPriceDomainObject.php b/backend/app/DomainObjects/ProductPriceDomainObject.php
index 2bd0d4fc4b..859029747f 100644
--- a/backend/app/DomainObjects/ProductPriceDomainObject.php
+++ b/backend/app/DomainObjects/ProductPriceDomainObject.php
@@ -13,6 +13,8 @@ class ProductPriceDomainObject extends Generated\ProductPriceDomainObjectAbstrac
private ?float $priceBeforeDiscount = null;
+ private ?float $priceBeforeDiscountIncludingTaxesAndFees = null;
+
private ?float $taxTotal = null;
private ?float $feeTotal = null;
@@ -42,6 +44,18 @@ public function setPriceBeforeDiscount(?float $originalPrice): ProductPriceDomai
return $this;
}
+ public function getPriceBeforeDiscountIncludingTaxesAndFees(): ?float
+ {
+ return $this->priceBeforeDiscountIncludingTaxesAndFees;
+ }
+
+ public function setPriceBeforeDiscountIncludingTaxesAndFees(?float $priceBeforeDiscountIncludingTaxesAndFees): self
+ {
+ $this->priceBeforeDiscountIncludingTaxesAndFees = $priceBeforeDiscountIncludingTaxesAndFees;
+
+ return $this;
+ }
+
public function getTaxTotal(): ?float
{
return $this->taxTotal ?? 0.00;
diff --git a/backend/app/Helper/Url.php b/backend/app/Helper/Url.php
index d83e9b5d13..d3bbf21f16 100644
--- a/backend/app/Helper/Url.php
+++ b/backend/app/Helper/Url.php
@@ -66,10 +66,10 @@ public static function getApiUrl(string $path, array $queryParams = []): string
*/
public static function getCdnUrl(string $path): string
{
- $envCDNUrl = env('APP_CDN_URL');
+ $cdnUrl = config('app.cdn_url');
- if ($envCDNUrl) {
- return $envCDNUrl.'/'.$path;
+ if ($cdnUrl) {
+ return $cdnUrl.'/'.$path;
}
$disk = config('filesystems.public', 'public');
diff --git a/backend/app/Http/Actions/Admin/Users/StopImpersonationAction.php b/backend/app/Http/Actions/Admin/Users/StopImpersonationAction.php
index 01f1748e0d..7ea88d1a9c 100644
--- a/backend/app/Http/Actions/Admin/Users/StopImpersonationAction.php
+++ b/backend/app/Http/Actions/Admin/Users/StopImpersonationAction.php
@@ -28,6 +28,7 @@ public function __invoke(): JsonResponse
}
$impersonatorId = $this->authManager->payload()->get('impersonator_id');
+ $presentedToken = $this->authManager->getToken();
try {
$token = $this->handler->handle(new StopImpersonationDTO(
@@ -37,6 +38,8 @@ public function __invoke(): JsonResponse
return $this->errorResponse($exception->getMessage(), ResponseCodes::HTTP_FORBIDDEN);
}
+ $this->authManager->manager()->invalidate($presentedToken);
+
$response = $this->jsonResponse([
'message' => __('Impersonation ended'),
'redirect_url' => '/admin/users',
diff --git a/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php b/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php
index 7903af585d..a94aa592f2 100644
--- a/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php
+++ b/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php
@@ -18,6 +18,9 @@ public function __construct(
private readonly CheckInAttendeeHandler $checkInAttendeeHandler
) {}
+ /**
+ * @deprecated This endpoint only sets a legacy flag on the attendee. The check-in isn't recorded on a check-in list, so it doesn't appear in the dashboard, check-in counts or exports, and no `checkin.created` or `checkin.deleted` webhook is sent. Use a check-in list instead: get the list's `short_id` from `GET /events/{eventId}/check-in-lists` (every event has a default list), then check attendees in with `POST /public/check-in-lists/{checkInListShortId}/check-ins` and out with `DELETE /public/check-in-lists/{checkInListShortId}/check-ins/{checkInShortId}`.
+ */
public function __invoke(CheckInAttendeeRequest $request, int $eventId, string $attendeePublicId): JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);
diff --git a/backend/app/Http/Actions/Auth/GetUserInvitationAction.php b/backend/app/Http/Actions/Auth/GetUserInvitationAction.php
index c285b04f3f..4961ffdd25 100644
--- a/backend/app/Http/Actions/Auth/GetUserInvitationAction.php
+++ b/backend/app/Http/Actions/Auth/GetUserInvitationAction.php
@@ -6,7 +6,7 @@
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\ResponseCodes;
use HiEvents\Resources\Auth\InvitationResource;
-use HiEvents\Services\Domain\Auth\Invitation\InvitationAcceptanceService;
+use HiEvents\Services\Application\Handlers\Auth\GetInvitationHandler;
use HiEvents\Services\Infrastructure\Encryption\Exception\DecryptionFailedException;
use HiEvents\Services\Infrastructure\Encryption\Exception\EncryptedPayloadExpiredException;
use Illuminate\Http\JsonResponse;
@@ -16,13 +16,13 @@
class GetUserInvitationAction extends BaseAction
{
public function __construct(
- private readonly InvitationAcceptanceService $invitationAcceptanceService,
+ private readonly GetInvitationHandler $handler,
) {}
public function __invoke(string $inviteToken): JsonResponse
{
try {
- $invitation = $this->invitationAcceptanceService->findPending($inviteToken);
+ $invitation = $this->handler->handle($inviteToken);
} catch (EncryptedPayloadExpiredException) {
throw new HttpException(ResponseCodes::HTTP_GONE, __('The invitation has expired'));
} catch (DecryptionFailedException) {
diff --git a/backend/app/Http/Actions/Auth/ResetPasswordAction.php b/backend/app/Http/Actions/Auth/ResetPasswordAction.php
index 583e7bfa74..35d6203e18 100644
--- a/backend/app/Http/Actions/Auth/ResetPasswordAction.php
+++ b/backend/app/Http/Actions/Auth/ResetPasswordAction.php
@@ -36,7 +36,7 @@ public function __invoke(ResetPasswordRequest $request): JsonResponse
));
} catch (PasswordInvalidException $exception) {
throw ValidationException::withMessages([
- 'current_password' => $exception->getMessage(),
+ 'password' => $exception->getMessage(),
]);
} catch (InvalidPasswordResetTokenException $e) {
throw new ResourceNotFoundException($e->getMessage());
diff --git a/backend/app/Http/Actions/Auth/Sso/GetSsoIdentitiesAction.php b/backend/app/Http/Actions/Auth/Sso/GetSsoIdentitiesAction.php
index 4f637849e5..fc68b36e84 100644
--- a/backend/app/Http/Actions/Auth/Sso/GetSsoIdentitiesAction.php
+++ b/backend/app/Http/Actions/Auth/Sso/GetSsoIdentitiesAction.php
@@ -2,6 +2,7 @@
namespace HiEvents\Http\Actions\Auth\Sso;
+use HiEvents\Enterprise\Sso\Services\Domain\DTO\LinkableSsoConnection;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Attributes\AllowedForReadOnlyUsers;
use HiEvents\Resources\Auth\Sso\UserIdentityResource;
@@ -17,9 +18,22 @@ public function __construct(
public function __invoke(): JsonResponse
{
+ $result = $this->handler->handle($this->getAuthenticatedUser()->getId());
+
return $this->resourceResponse(
UserIdentityResource::class,
- $this->handler->handle($this->getAuthenticatedUser()->getId()),
+ $result->identities,
+ meta: [
+ 'linkable_connections' => $result->linkableConnections
+ ->map(fn (LinkableSsoConnection $connection) => [
+ 'key' => $connection->key,
+ 'name' => $connection->name,
+ 'type' => $connection->type->value,
+ 'logo_url' => null,
+ 'account_name' => $connection->accountName,
+ ])
+ ->all(),
+ ],
);
}
}
diff --git a/backend/app/Http/Actions/BaseAction.php b/backend/app/Http/Actions/BaseAction.php
index 244b1a6f25..63c21e93e0 100644
--- a/backend/app/Http/Actions/BaseAction.php
+++ b/backend/app/Http/Actions/BaseAction.php
@@ -54,13 +54,14 @@ protected function filterableResourceResponse(
int $statusCode = ResponseCodes::HTTP_OK,
): JsonResponse {
$metaFields = [];
+ $withheldFields = $this->isWithheldFromCurrentUser(Permission::VIEW_FINANCIALS) ? self::FINANCIAL_QUERY_FIELDS : [];
if (is_a($domainObject, IsFilterable::class, true)) {
- $metaFields['allowed_filter_fields'] = $domainObject::getAllowedFilterFields();
+ $metaFields['allowed_filter_fields'] = array_values(array_diff($domainObject::getAllowedFilterFields(), $withheldFields));
}
if (is_a($domainObject, IsSortable::class, true)) {
- $metaFields['allowed_sorts'] = $domainObject::getAllowedSorts()->toArray();
+ $metaFields['allowed_sorts'] = array_diff_key($domainObject::getAllowedSorts()->toArray(), array_flip($withheldFields));
$metaFields['default_sort'] = $domainObject::getDefaultSort();
$metaFields['default_sort_direction'] = $domainObject::getDefaultSortDirection();
}
diff --git a/backend/app/Http/Actions/Questions/CreateQuestionAction.php b/backend/app/Http/Actions/Questions/CreateQuestionAction.php
index 9964ccba50..a1db810f74 100644
--- a/backend/app/Http/Actions/Questions/CreateQuestionAction.php
+++ b/backend/app/Http/Actions/Questions/CreateQuestionAction.php
@@ -33,7 +33,7 @@ public function __invoke(UpsertQuestionRequest $request, int $eventId): JsonResp
'required' => $request->boolean('required'),
'options' => $request->input('options'),
'event_id' => $eventId,
- 'product_ids' => $request->input('product_ids'),
+ 'product_ids' => $request->input('product_ids', []),
'belongs_to' => $request->input('belongs_to'),
'is_hidden' => $request->boolean('is_hidden'),
'description' => $request->input('description'),
diff --git a/backend/app/Http/Actions/Questions/EditQuestionAction.php b/backend/app/Http/Actions/Questions/EditQuestionAction.php
index c7d27dcd6c..4361d1241e 100644
--- a/backend/app/Http/Actions/Questions/EditQuestionAction.php
+++ b/backend/app/Http/Actions/Questions/EditQuestionAction.php
@@ -40,7 +40,7 @@ public function __invoke(UpsertQuestionRequest $request, int $eventId, int $ques
'required' => $request->boolean('required'),
'options' => $request->input('options'),
'event_id' => $eventId,
- 'product_ids' => $request->input('product_ids'),
+ 'product_ids' => $request->input('product_ids', []),
'is_hidden' => $request->boolean('is_hidden'),
'belongs_to' => QuestionBelongsTo::fromName($request->input('belongs_to')),
'description' => $request->input('description'),
diff --git a/backend/app/Http/Actions/TaxesAndFees/EditTaxOrFeeAction.php b/backend/app/Http/Actions/TaxesAndFees/EditTaxOrFeeAction.php
index 2aeaa2fe2a..89d67d53f7 100644
--- a/backend/app/Http/Actions/TaxesAndFees/EditTaxOrFeeAction.php
+++ b/backend/app/Http/Actions/TaxesAndFees/EditTaxOrFeeAction.php
@@ -24,14 +24,14 @@ public function __construct(EditTaxHandler $taxHandler)
/**
* @throws ValidationException
*/
- public function __invoke(CreateTaxOrFeeRequest $request, int $accountId, int $taxId): JsonResponse
+ public function __invoke(CreateTaxOrFeeRequest $request, int $accountId, int $taxOrFeeId): JsonResponse
{
- $this->isActionAuthorized($taxId, TaxAndFeesDomainObject::class);
+ $this->isActionAuthorized($taxOrFeeId, TaxAndFeesDomainObject::class);
try {
$payload = array_merge($request->validated(), [
'account_id' => $this->getAuthenticatedAccountId(),
- 'id' => $taxId,
+ 'id' => $taxOrFeeId,
]);
$tax = $this->taxHandler->handle(UpsertTaxDTO::fromArray($payload));
diff --git a/backend/app/Http/Actions/Widgets/BaseUpsertOrganizerWidgetAction.php b/backend/app/Http/Actions/Widgets/BaseUpsertOrganizerWidgetAction.php
index 5e2abe1ea5..073b03a2c3 100644
--- a/backend/app/Http/Actions/Widgets/BaseUpsertOrganizerWidgetAction.php
+++ b/backend/app/Http/Actions/Widgets/BaseUpsertOrganizerWidgetAction.php
@@ -4,7 +4,6 @@
namespace HiEvents\Http\Actions\Widgets;
-use HiEvents\Exceptions\InvalidWidgetSettingsException;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Widget\UpsertOrganizerWidgetRequest;
@@ -13,7 +12,6 @@
use HiEvents\Services\Application\Handlers\Widget\DTO\UpsertWidgetDTO;
use HiEvents\Services\Application\Handlers\Widget\UpsertWidgetHandler;
use Illuminate\Http\JsonResponse;
-use Illuminate\Validation\ValidationException;
abstract class BaseUpsertOrganizerWidgetAction extends BaseAction
{
@@ -23,26 +21,21 @@ public function __construct(
) {}
/**
- * @throws ValidationException
* @throws ResourceNotFoundException
*/
protected function upsert(UpsertOrganizerWidgetRequest $request, int $organizerId, ?int $widgetId, int $statusCode): JsonResponse
{
$organizer = $this->organizerRepository->findById($organizerId);
- try {
- $widget = $this->handler->handle(new UpsertWidgetDTO(
- account_id: $organizer->getAccountId(),
- organizer_id: $organizerId,
- event_id: null,
- name: $request->validated('name'),
- type: $request->widgetType(),
- settings: $request->validated('settings') ?? [],
- widget_id: $widgetId,
- ));
- } catch (InvalidWidgetSettingsException $exception) {
- throw ValidationException::withMessages([$exception->getField() => $exception->getMessage()]);
- }
+ $widget = $this->handler->handle(new UpsertWidgetDTO(
+ account_id: $organizer->getAccountId(),
+ organizer_id: $organizerId,
+ event_id: null,
+ name: $request->validated('name'),
+ type: $request->widgetType(),
+ settings: $request->validated('settings') ?? [],
+ widget_id: $widgetId,
+ ));
return $this->resourceResponse(
resource: WidgetResource::class,
diff --git a/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php b/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php
index 1dfe957c56..ca80fd4a89 100644
--- a/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php
+++ b/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php
@@ -59,6 +59,7 @@ public function rules(): array
'recurrence_rule.days_of_month' => ['nullable', 'array'],
'recurrence_rule.days_of_month.*' => ['integer', 'min:1', 'max:31'],
'recurrence_rule.day_of_week' => ['nullable', 'string', 'in:monday,tuesday,wednesday,thursday,friday,saturday,sunday'],
+ /** @var -1|1|2|3|4|null */
'recurrence_rule.week_position' => ['nullable', 'integer', 'in:-1,1,2,3,4'],
'recurrence_rule.month' => ['nullable', 'integer', 'min:1', 'max:12'],
];
diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php
index 92f13e7c29..9b9f2076ad 100644
--- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php
+++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php
@@ -7,16 +7,36 @@
use HiEvents\DomainObjects\Enums\HomepageFontFamily;
use HiEvents\DomainObjects\Enums\HomepageSectionType;
use HiEvents\DomainObjects\Enums\PaymentProviders;
+use HiEvents\DomainObjects\Enums\Permission;
use HiEvents\DomainObjects\Enums\PriceDisplayMode;
use HiEvents\DomainObjects\Enums\TicketDateDisplayMode;
+use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Http\Request\BaseRequest;
+use HiEvents\Repository\Interfaces\EventRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
+use HiEvents\Services\Domain\Auth\Access\AccountUserAccessService;
+use HiEvents\Services\Domain\Auth\AuthUserService;
+use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
use HiEvents\Validators\Rules\HomepageSectionRules;
use HiEvents\Validators\Rules\RulesHelper;
use Illuminate\Validation\Rule;
+use Illuminate\Validation\Validator;
class UpdateEventSettingsRequest extends BaseRequest
{
- // @todo these should all be required for the update request. They should only be nullable for the PATCH request
+ public function after(): array
+ {
+ return [
+ function (Validator $validator): void {
+ $refusal = $this->offlinePaymentInstructionsChangeRefusal();
+
+ if ($refusal !== null && $this->changesOfflinePaymentInstructions()) {
+ $validator->errors()->add('offline_payment_instructions', $refusal);
+ }
+ },
+ ];
+ }
+
public function rules(): array
{
return [
@@ -147,4 +167,63 @@ public function messages(): array
...HomepageSectionRules::messages(),
];
}
+
+ private function offlinePaymentInstructionsChangeRefusal(): ?string
+ {
+ if (app(AuthUserService::class)->isApiKeyRequest()) {
+ return __('Offline payment instructions can only be changed from the dashboard, not with an API key.');
+ }
+
+ if (app(AccountUserAccessService::class)->isWithheldFromCurrentUser(Permission::MANAGE_PAYMENTS)) {
+ return __('Only members who can manage payments can change the offline payment instructions.');
+ }
+
+ return null;
+ }
+
+ private function changesOfflinePaymentInstructions(): bool
+ {
+ if (! $this->isMethod('PUT') && ! $this->exists('offline_payment_instructions')) {
+ return false;
+ }
+
+ $submitted = $this->input('offline_payment_instructions');
+
+ if ($submitted !== null && ! is_string($submitted)) {
+ return false;
+ }
+
+ $event = $this->eventTheMemberCanEdit();
+
+ if ($event === null) {
+ return false;
+ }
+
+ $saved = trim((string) app(EventSettingsRepositoryInterface::class)
+ ->findFirstWhere(['event_id' => $event->getId()])
+ ?->getOfflinePaymentInstructions());
+
+ if (blank($submitted)) {
+ return $saved !== '';
+ }
+
+ $submitted = trim($submitted);
+
+ return $submitted !== $saved
+ && trim(app(HtmlPurifierService::class)->purify($submitted)) !== $saved;
+ }
+
+ private function eventTheMemberCanEdit(): ?EventDomainObject
+ {
+ $event = app(EventRepositoryInterface::class)->findFirstWhere([
+ 'id' => (int) $this->route('event_id'),
+ 'account_id' => app(AuthUserService::class)->getAuthenticatedAccountId(),
+ ]);
+
+ if ($event === null || ! app(AccountUserAccessService::class)->currentMemberCanAccessEvent($event)) {
+ return null;
+ }
+
+ return $event;
+ }
}
diff --git a/backend/app/Http/Request/Lineup/SortLineupEntriesRequest.php b/backend/app/Http/Request/Lineup/SortLineupEntriesRequest.php
index 1ed02f9b46..726452166e 100644
--- a/backend/app/Http/Request/Lineup/SortLineupEntriesRequest.php
+++ b/backend/app/Http/Request/Lineup/SortLineupEntriesRequest.php
@@ -20,9 +20,13 @@ public function rules(): array
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
- $ids = $this->input('sorted_ids', []);
+ if ($validator->errors()->isNotEmpty()) {
+ return;
+ }
+
+ $ids = $this->input('sorted_ids');
- if (is_array($ids) && count($ids) !== count(array_unique($ids))) {
+ if (count($ids) !== count(array_unique($ids))) {
$validator->errors()->add('sorted_ids', __('Each lineup entry may only appear once'));
}
});
diff --git a/backend/app/Http/Request/Lineup/SortProfileImagesRequest.php b/backend/app/Http/Request/Lineup/SortProfileImagesRequest.php
index 78da430bf2..4e610b601c 100644
--- a/backend/app/Http/Request/Lineup/SortProfileImagesRequest.php
+++ b/backend/app/Http/Request/Lineup/SortProfileImagesRequest.php
@@ -20,9 +20,13 @@ public function rules(): array
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
- $ids = $this->input('sorted_ids', []);
+ if ($validator->errors()->isNotEmpty()) {
+ return;
+ }
+
+ $ids = $this->input('sorted_ids');
- if (is_array($ids) && count($ids) !== count(array_unique($ids))) {
+ if (count($ids) !== count(array_unique($ids))) {
$validator->errors()->add('sorted_ids', __('Each photo may only appear once'));
}
});
diff --git a/backend/app/Http/Request/Lineup/UpsertScheduleItemRequest.php b/backend/app/Http/Request/Lineup/UpsertScheduleItemRequest.php
index 88f1b4683a..4cc77ed306 100644
--- a/backend/app/Http/Request/Lineup/UpsertScheduleItemRequest.php
+++ b/backend/app/Http/Request/Lineup/UpsertScheduleItemRequest.php
@@ -25,15 +25,16 @@ public function rules(): array
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
+ $errors = $validator->errors();
$entryIds = $this->input('lineup_entry_ids', []);
- $title = trim((string) $this->input('title', ''));
+ $title = $this->input('title');
- if ($title === '' && (! is_array($entryIds) || $entryIds === [])) {
- $validator->errors()->add('title', __('Add a title or at least one person from the lineup'));
+ if (! $errors->has('title') && trim(is_string($title) ? $title : '') === '' && (! is_array($entryIds) || $entryIds === [])) {
+ $errors->add('title', __('Add a title or at least one person from the lineup'));
}
- if (is_array($entryIds) && count($entryIds) !== count(array_unique($entryIds))) {
- $validator->errors()->add('lineup_entry_ids', __('Each person may only be added once'));
+ if (! $errors->hasAny(['lineup_entry_ids', 'lineup_entry_ids.*']) && is_array($entryIds) && count($entryIds) !== count(array_unique($entryIds))) {
+ $errors->add('lineup_entry_ids', __('Each person may only be added once'));
}
});
}
diff --git a/backend/app/Http/Request/Questions/UpsertQuestionRequest.php b/backend/app/Http/Request/Questions/UpsertQuestionRequest.php
index a383a0036d..d1b1dee0f7 100644
--- a/backend/app/Http/Request/Questions/UpsertQuestionRequest.php
+++ b/backend/app/Http/Request/Questions/UpsertQuestionRequest.php
@@ -16,9 +16,8 @@ public function rules(): array
'description' => ['string', 'nullable', 'max:10000'],
'type' => ['required', Rule::in(QuestionTypeEnum::valuesArray())],
'product_ids' => ['array', 'required_if:belongs_to,PRODUCT'],
- 'belongs_to' => [
- ['required', Rule::in([QuestionBelongsTo::PRODUCT->name, QuestionBelongsTo::ORDER->name])],
- ],
+ 'belongs_to' => ['required', Rule::in([QuestionBelongsTo::PRODUCT->name, QuestionBelongsTo::ORDER->name])],
+ /** @var string[]|null */
'options' => 'max:2000|required_if:type,CHECKBOX,RADIO',
'required' => 'required|boolean',
'is_hidden' => 'required|boolean',
diff --git a/backend/app/Mail/Account/AdminAccessGrantedMail.php b/backend/app/Mail/Account/AdminAccessGrantedMail.php
index 33966dbc3d..ff137cc571 100644
--- a/backend/app/Mail/Account/AdminAccessGrantedMail.php
+++ b/backend/app/Mail/Account/AdminAccessGrantedMail.php
@@ -12,11 +12,15 @@
*/
class AdminAccessGrantedMail extends BaseMail
{
+ private ?bool $isInvitation = null;
+
public function __construct(
private readonly string $newAdminEmail,
private readonly ?string $grantedByEmail = null,
+ ?bool $isInvitation = null,
) {
parent::__construct();
+ $this->isInvitation = $isInvitation;
}
public function envelope(): Envelope
@@ -33,6 +37,7 @@ public function content(): Content
with: [
'newAdminEmail' => $this->newAdminEmail,
'grantedByEmail' => $this->grantedByEmail,
+ 'isInvitation' => $this->isInvitation === true,
'teamUrl' => Url::getFrontEndUrlFromConfig(Url::ACCOUNT_USERS),
]
);
diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php
index 6457ba2c0b..c8a80a96f6 100644
--- a/backend/app/Mail/Attendee/AttendeeTicketMail.php
+++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php
@@ -121,6 +121,8 @@ private function ticketViewData(AttendeeDomainObject $attendee): array
return [
'attendee' => $attendee,
'occurrence' => $occurrence,
+ 'startDate' => $this->startDateFor($occurrence),
+ 'endDate' => $this->endDateFor($occurrence),
'venueName' => $this->venueNameFor($eventLocation),
'addressString' => $this->addressStringFor($eventLocation),
'ticketUrl' => sprintf(
@@ -131,6 +133,23 @@ private function ticketViewData(AttendeeDomainObject $attendee): array
];
}
+ private function startDateFor(?EventOccurrenceDomainObject $occurrence): ?string
+ {
+ return $occurrence?->getStartDate() ?? $this->event->getStartDate();
+ }
+
+ private function endDateFor(?EventOccurrenceDomainObject $occurrence): ?string
+ {
+ $start = $this->startDateFor($occurrence);
+ $end = $occurrence === null ? $this->event->getEndDate() : $occurrence->getEndDate();
+
+ if ($start === null || $end === null || ! Carbon::parse($end)->greaterThan(Carbon::parse($start))) {
+ return null;
+ }
+
+ return $end;
+ }
+
private function venueNameFor(?EventLocationDomainObject $eventLocation): ?string
{
$venue = $this->venueLocation($eventLocation);
@@ -203,8 +222,8 @@ private function calendarEventFor(AttendeeDomainObject $attendee): ?Event
{
$occurrence = $this->occurrenceFor($attendee);
- $startDateRaw = $occurrence?->getStartDate() ?? $this->event->getStartDate();
- $endDateRaw = $occurrence?->getEndDate() ?? $this->event->getEndDate();
+ $startDateRaw = $this->startDateFor($occurrence);
+ $endDateRaw = $this->endDateFor($occurrence);
$startDateTime = $startDateRaw ? Carbon::parse($startDateRaw, $this->event->getTimezone()) : null;
$endDateTime = $endDateRaw ? Carbon::parse($endDateRaw, $this->event->getTimezone()) : null;
diff --git a/backend/app/OpenApi/IntegrationApiReference.php b/backend/app/OpenApi/IntegrationApiReference.php
index 8c6c10e29d..9cd18d2fd2 100644
--- a/backend/app/OpenApi/IntegrationApiReference.php
+++ b/backend/app/OpenApi/IntegrationApiReference.php
@@ -7,6 +7,7 @@
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Scramble;
use Illuminate\Contracts\Foundation\Application;
+use stdClass;
class IntegrationApiReference
{
@@ -33,32 +34,33 @@ public function generate(): string
{
$document = json_decode(
json_encode($this->app->make(Generator::class)(Scramble::getGeneratorConfig('default')), JSON_THROW_ON_ERROR),
- true,
flags: JSON_THROW_ON_ERROR,
);
- unset($document['servers'], $document['info']['version']);
+ unset($document->servers, $document->info->version);
- $document['paths'] = array_filter(
- array_map($this->withoutSessionOnlyOperations(...), $document['paths']),
- static fn (array $pathItem, string $path) => $pathItem !== [] && ! self::isExcludedPath($path),
- ARRAY_FILTER_USE_BOTH,
- );
+ $paths = [];
+ foreach ((array) $document->paths as $path => $pathItem) {
+ $pathItem = $this->withoutSessionOnlyOperations($pathItem);
+
+ if ($pathItem !== null && ! self::isExcludedPath($path)) {
+ $paths[$path] = $pathItem;
+ }
+ }
+ $document->paths = (object) $paths;
- $document = $this->withReachableSchemasOnly($document);
+ $this->keepReachableSchemasOnly($document);
+ self::closeTuplesWithItems($document);
$json = json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
return preg_replace_callback('/^(?: {4})+/m', static fn (array $indent) => substr($indent[0], 0, intdiv(strlen($indent[0]), 2)), $json)."\n";
}
- /**
- * @return array Yes Yes Yes '.str_repeat('a', 5000).' Old bio New bio Hello Doors open here Hello Notes See you there
-
diff --git a/backend/resources/views/vendor/mail/html/table.blade.php b/backend/resources/views/vendor/mail/html/table.blade.php
index b3bb3c8e4e..a5f3348b23 100644
--- a/backend/resources/views/vendor/mail/html/table.blade.php
+++ b/backend/resources/views/vendor/mail/html/table.blade.php
@@ -1,3 +1,3 @@
-
+
-
-
-
-
-
-
- {{ Illuminate\Mail\Markdown::parse($slot) }}
-
-
+
+
+
+
+
+
+
+{{ Illuminate\Mail\Markdown::parse($slot) }}
+
+
Pay to IBAN GB00 ATTACKER 9999
'; + + private const MEMBER_REFUSAL = 'Only members who can manage payments can change the offline payment instructions.'; + + private const API_KEY_REFUSAL = 'Offline payment instructions can only be changed from the dashboard, not with an API key.'; + + private User $owner; + + private int $accountId; + + private int $eventId; + + protected function setUp(): void + { + parent::setUp(); + + [$this->owner, $this->accountId] = $this->createAccountOwner(); + $organizerId = $this->createOrganizer($this->accountId); + DB::table('organizer_settings')->insert(['organizer_id' => $organizerId, 'created_at' => now(), 'updated_at' => now()]); + + $this->eventId = $this->postJson('/events', [ + 'title' => 'Offline payments', + 'type' => 'SINGLE', + 'organizer_id' => $organizerId, + 'start_date' => '2030-06-14 19:00', + 'currency' => 'EUR', + 'timezone' => 'UTC', + ], $this->headersFor($this->owner, $this->accountId))->assertSuccessful()->json('data.id'); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => self::SAVED_INSTRUCTIONS, + ], $this->headersFor($this->owner, $this->accountId))->assertOk(); + } + + public function test_admins_can_change_the_offline_payment_instructions(): void + { + $admin = $this->addAccountUser($this->accountId, Role::ADMIN); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => 'New bank details
', + ], $this->headersFor($admin, $this->accountId))->assertOk(); + + $this->assertSame('New bank details
', $this->savedInstructions()); + } + + public function test_an_organizer_without_manage_payments_cannot_change_the_offline_payment_instructions(): void + { + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => self::ATTACKER_INSTRUCTIONS, + ], $this->headersFor($organizer, $this->accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['offline_payment_instructions' => self::MEMBER_REFUSAL]); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE'], + 'offline_payment_instructions' => null, + ], $this->headersFor($organizer, $this->accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['offline_payment_instructions' => self::MEMBER_REFUSAL]); + + $this->putJson("/events/{$this->eventId}/settings", $this->fullSettings([ + 'offline_payment_instructions' => self::ATTACKER_INSTRUCTIONS, + ]), $this->headersFor($organizer, $this->accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['offline_payment_instructions' => self::MEMBER_REFUSAL]); + + $this->putJson("/events/{$this->eventId}/settings", $this->fullSettings([], without: 'offline_payment_instructions'), $this->headersFor($organizer, $this->accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['offline_payment_instructions' => self::MEMBER_REFUSAL]); + + $this->assertSame(self::SAVED_INSTRUCTIONS, $this->savedInstructions()); + $this->assertSame(['STRIPE', 'OFFLINE'], $this->savedPaymentProviders()); + } + + public function test_an_organizer_without_manage_payments_can_save_other_settings_with_the_unchanged_instructions(): void + { + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => self::SAVED_INSTRUCTIONS, + 'require_billing_address' => false, + 'enable_invoicing' => true, + 'organization_name' => 'Acme Events', + 'organization_address' => '1 Main Street
', + ], $this->headersFor($organizer, $this->accountId))->assertOk(); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'seo_title' => 'Updated title', + ], $this->headersFor($organizer, $this->accountId))->assertOk(); + + $this->putJson("/events/{$this->eventId}/settings", $this->fullSettings([ + 'seo_title' => 'Round-tripped title', + ]), $this->headersFor($organizer, $this->accountId))->assertOk(); + + $settings = DB::table('event_settings')->where('event_id', $this->eventId)->first(); + $this->assertSame(self::SAVED_INSTRUCTIONS, $settings->offline_payment_instructions); + $this->assertSame('Round-tripped title', $settings->seo_title); + $this->assertFalse((bool) $settings->require_billing_address); + } + + public function test_re_sending_instructions_that_purify_to_the_saved_value_is_not_a_change(): void + { + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => 'Pay by transfer
', + ], $this->headersFor($this->owner, $this->accountId))->assertOk(); + + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => 'Pay by transfer
', + ], $this->headersFor($organizer, $this->accountId))->assertOk(); + + $this->assertSame('Pay by transfer
', $this->savedInstructions()); + } + + public function test_an_api_key_cannot_change_the_instructions_even_for_an_admin(): void + { + $apiKey = $this->createApiKey($this->owner, $this->accountId); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => self::ATTACKER_INSTRUCTIONS, + ], $this->apiKeyHeaders($apiKey)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['offline_payment_instructions' => self::API_KEY_REFUSAL]); + + $this->putJson("/events/{$this->eventId}/settings", $this->fullSettings([ + 'offline_payment_instructions' => self::ATTACKER_INSTRUCTIONS, + ]), $this->apiKeyHeaders($apiKey)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['offline_payment_instructions' => self::API_KEY_REFUSAL]); + + $this->assertSame(self::SAVED_INSTRUCTIONS, $this->savedInstructions()); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => self::SAVED_INSTRUCTIONS, + 'require_billing_address' => false, + ], $this->apiKeyHeaders($apiKey))->assertOk(); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'seo_title' => 'Set with a key', + ], $this->apiKeyHeaders($apiKey))->assertOk(); + + $this->putJson("/events/{$this->eventId}/settings", $this->fullSettings([ + 'seo_title' => 'Put with a key', + ]), $this->apiKeyHeaders($apiKey))->assertOk(); + + $this->assertSame(self::SAVED_INSTRUCTIONS, $this->savedInstructions()); + } + + public function test_blank_instructions_are_unchanged_when_none_are_saved(): void + { + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE'], + 'offline_payment_instructions' => null, + ], $this->headersFor($this->owner, $this->accountId))->assertOk(); + + $apiKey = $this->createApiKey($this->owner, $this->accountId); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'payment_providers' => ['STRIPE'], + 'offline_payment_instructions' => '', + ], $this->apiKeyHeaders($apiKey))->assertOk(); + + $this->patchJson("/events/{$this->eventId}/settings", [ + 'offline_payment_instructions' => self::ATTACKER_INSTRUCTIONS, + ], $this->apiKeyHeaders($apiKey)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['offline_payment_instructions' => self::API_KEY_REFUSAL]); + } + + private function savedInstructions(): ?string + { + return DB::table('event_settings')->where('event_id', $this->eventId)->value('offline_payment_instructions'); + } + + private function savedPaymentProviders(): array + { + return json_decode(DB::table('event_settings')->where('event_id', $this->eventId)->value('payment_providers'), true); + } + + private function fullSettings(array $overrides, ?string $without = null): array + { + $settings = $this->getJson("/events/{$this->eventId}/settings", $this->headersFor($this->owner, $this->accountId)) + ->assertOk() + ->json('data'); + + $settings['require_attendee_details'] = $settings['required_attendee_details']; + unset($settings['required_attendee_details']); + + $settings = array_merge($settings, $overrides); + unset($settings[$without]); + + return $settings; + } +} diff --git a/backend/tests/Feature/Http/TeamAccess/OfflinePaymentInstructionsChangeDetectionTest.php b/backend/tests/Feature/Http/TeamAccess/OfflinePaymentInstructionsChangeDetectionTest.php new file mode 100644 index 0000000000..ebfbf604be --- /dev/null +++ b/backend/tests/Feature/Http/TeamAccess/OfflinePaymentInstructionsChangeDetectionTest.php @@ -0,0 +1,145 @@ +Pay to IBAN IE00 OWNER 0000\n"; + + private const MEMBER_REFUSAL = 'Only members who can manage payments can change the offline payment instructions.'; + + private User $owner; + + private int $accountId; + + private int $organizerId; + + protected function setUp(): void + { + parent::setUp(); + + [$this->owner, $this->accountId] = $this->createAccountOwner(); + $this->organizerId = $this->createOrganizer($this->accountId); + DB::table('organizer_settings')->insert(['organizer_id' => $this->organizerId, 'created_at' => now(), 'updated_at' => now()]); + } + + public function test_re_sending_saved_instructions_that_have_surrounding_whitespace_is_not_a_change(): void + { + $eventId = $this->createEventWithInstructions(self::PADDED_INSTRUCTIONS); + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + $saved = $this->getJson("/events/$eventId/settings", $this->headersFor($organizer, $this->accountId)) + ->assertOk() + ->json('data.offline_payment_instructions'); + $this->assertSame(self::PADDED_INSTRUCTIONS, $saved); + + $this->patchJson("/events/$eventId/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => $saved, + 'seo_title' => 'Updated by an organizer', + ], $this->headersFor($organizer, $this->accountId))->assertOk(); + + $this->assertSame('Updated by an organizer', DB::table('event_settings')->where('event_id', $eventId)->value('seo_title')); + + $this->putJson("/events/$eventId/settings", $this->fullSettings($eventId, [ + 'seo_title' => 'Put by an organizer', + ]), $this->headersFor($organizer, $this->accountId))->assertOk(); + + $apiKey = $this->createApiKey($this->owner, $this->accountId); + $this->patchJson("/events/$eventId/settings", [ + 'offline_payment_instructions' => self::PADDED_INSTRUCTIONS, + ], $this->apiKeyHeaders($apiKey))->assertOk(); + } + + public function test_a_real_change_to_padded_saved_instructions_is_still_refused(): void + { + $eventId = $this->createEventWithInstructions(self::PADDED_INSTRUCTIONS); + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + $this->patchJson("/events/$eventId/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => 'Pay to IBAN GB00 ATTACKER 9999
', + ], $this->headersFor($organizer, $this->accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['offline_payment_instructions' => self::MEMBER_REFUSAL]); + + $this->patchJson("/events/$eventId/settings", [ + 'payment_providers' => ['STRIPE'], + 'offline_payment_instructions' => null, + ], $this->headersFor($organizer, $this->accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['offline_payment_instructions' => self::MEMBER_REFUSAL]); + + $this->assertSame(self::PADDED_INSTRUCTIONS, DB::table('event_settings')->where('event_id', $eventId)->value('offline_payment_instructions')); + } + + public function test_a_member_without_access_to_the_event_is_refused_the_same_way_whether_or_not_instructions_are_saved(): void + { + $accessibleEventId = $this->createEventWithInstructions(null); + $withInstructions = $this->createEventWithInstructions('Pay to IBAN IE00 OWNER 0000
'); + $withoutInstructions = $this->createEventWithInstructions(null); + $member = $this->addRestrictedAccountUser($this->accountId, eventIds: [$accessibleEventId]); + + foreach ([$withInstructions, $withoutInstructions] as $eventId) { + $this->patchJson("/events/$eventId/settings", [ + 'payment_providers' => ['STRIPE'], + 'offline_payment_instructions' => null, + ], $this->headersFor($member, $this->accountId)) + ->assertForbidden() + ->assertJsonMissingPath('errors.offline_payment_instructions'); + } + + $this->patchJson("/events/$accessibleEventId/settings", [ + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => 'Pay to IBAN GB00 ATTACKER 9999
', + ], $this->headersFor($member, $this->accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['offline_payment_instructions' => self::MEMBER_REFUSAL]); + + $this->assertSame('Pay to IBAN IE00 OWNER 0000
', DB::table('event_settings')->where('event_id', $withInstructions)->value('offline_payment_instructions')); + $this->assertNull(DB::table('event_settings')->where('event_id', $accessibleEventId)->value('offline_payment_instructions')); + } + + private function createEventWithInstructions(?string $instructions): int + { + $eventId = $this->postJson('/events', [ + 'title' => 'Offline payments', + 'type' => 'SINGLE', + 'organizer_id' => $this->organizerId, + 'start_date' => '2030-06-14 19:00', + 'currency' => 'EUR', + 'timezone' => 'UTC', + ], $this->headersFor($this->owner, $this->accountId))->assertSuccessful()->json('data.id'); + + DB::table('event_settings')->where('event_id', $eventId)->update([ + 'payment_providers' => json_encode($instructions === null ? ['STRIPE'] : ['STRIPE', 'OFFLINE']), + 'offline_payment_instructions' => $instructions, + ]); + + return $eventId; + } + + private function fullSettings(int $eventId, array $overrides): array + { + $settings = $this->getJson("/events/$eventId/settings", $this->headersFor($this->owner, $this->accountId)) + ->assertOk() + ->json('data'); + + $settings['require_attendee_details'] = $settings['required_attendee_details']; + unset($settings['required_attendee_details']); + + return array_merge($settings, $overrides); + } +} diff --git a/backend/tests/Feature/Http/TeamAccess/PaymentSettingsPermissionTest.php b/backend/tests/Feature/Http/TeamAccess/PaymentSettingsPermissionTest.php new file mode 100644 index 0000000000..9b68092acf --- /dev/null +++ b/backend/tests/Feature/Http/TeamAccess/PaymentSettingsPermissionTest.php @@ -0,0 +1,135 @@ +accountId] = $this->createAccountOwner(); + $this->organizerId = $this->createOrganizer($this->accountId); + $this->otherOrganizerId = $this->createOrganizer($this->accountId); + } + + public function test_exactly_the_payment_settings_actions_require_manage_payments(): void + { + $gated = collect(app('router')->getRoutes()->getRoutes()) + ->map(fn (Route $route) => $route->getControllerClass()) + ->filter() + ->unique() + ->filter(fn (string $action) => collect((new ReflectionClass($action))->getAttributes(RequiresPermission::class)) + ->flatMap(fn ($attribute) => $attribute->newInstance()->permissions) + ->contains(Permission::MANAGE_PAYMENTS)) + ->sort() + ->values() + ->all(); + + $this->assertSame(collect(self::PAYMENT_SETTINGS_ACTIONS)->sort()->values()->all(), $gated); + } + + public static function paymentSettingsEndpoints(): array + { + return [ + 'connect stripe' => ['post', '/organizers/{organizer}/stripe/connect'], + 'copy stripe account' => ['post', '/organizers/{organizer}/stripe/copy_from/{other}'], + 'disconnect stripe' => ['delete', '/organizers/{organizer}/stripe/connect_accounts/acct_1'], + 'list stripe accounts' => ['get', '/organizers/{organizer}/stripe/connect_accounts'], + 'upsert vat settings' => ['post', '/organizers/{organizer}/vat-settings'], + 'list terminal readers' => ['get', '/organizers/{organizer}/stripe/terminal/readers'], + 'register terminal reader' => ['post', '/organizers/{organizer}/stripe/terminal/readers'], + 'delete terminal reader' => ['delete', '/organizers/{organizer}/stripe/terminal/readers/1'], + ]; + } + + #[DataProvider('paymentSettingsEndpoints')] + public function test_organizers_cannot_change_where_money_is_paid_out(string $method, string $path): void + { + foreach (self::PAYMENT_SETTINGS_HANDLERS as $handler) { + $this->mock($handler, fn (MockInterface $mock) => $mock->shouldNotReceive('handle')); + } + + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + $this->json($method, $this->path($path), [], $this->headersFor($organizer, $this->accountId)) + ->assertForbidden() + ->assertJsonPath('error_code', 'PERMISSION_REQUIRED'); + } + + public function test_admins_pass_the_payment_settings_gate(): void + { + $this->mock(DisconnectStripeConnectAccountHandler::class, fn (MockInterface $mock) => $mock + ->shouldReceive('handle') + ->once() + ->with($this->organizerId, $this->accountId, 'acct_1')); + + $admin = $this->addAccountUser($this->accountId, Role::ADMIN); + + $this->deleteJson($this->path('/organizers/{organizer}/stripe/connect_accounts/acct_1'), [], $this->headersFor($admin, $this->accountId)) + ->assertNoContent(); + } + + private function path(string $path): string + { + return str_replace(['{organizer}', '{other}'], [$this->organizerId, $this->otherOrganizerId], $path); + } +} diff --git a/backend/tests/Feature/Http/TeamAccess/TeamOverviewAndNotificationsTest.php b/backend/tests/Feature/Http/TeamAccess/TeamOverviewAndNotificationsTest.php index 824bd9587f..2a50feb2a6 100644 --- a/backend/tests/Feature/Http/TeamAccess/TeamOverviewAndNotificationsTest.php +++ b/backend/tests/Feature/Http/TeamAccess/TeamOverviewAndNotificationsTest.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\Enums\Role; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Status\UserStatus; +use HiEvents\DomainObjects\UserDomainObject; use HiEvents\Mail\Account\AdminAccessGrantedMail; use HiEvents\Models\User; use HiEvents\Repository\Interfaces\EventRepositoryInterface; @@ -104,7 +105,7 @@ public function test_order_notifications_reach_only_opted_in_members_who_can_see $recipients = $this->app->make(TeamOrderNotificationRecipientService::class)->recipientsFor($this->event(), null); - $this->assertSame([strtolower($subscribed->email)], $recipients); + $this->assertSame([strtolower($subscribed->email)], array_map(fn (UserDomainObject $user) => strtolower($user->getEmail()), $recipients)); } public function test_the_organizer_address_is_not_emailed_twice(): void @@ -155,6 +156,49 @@ public function test_owner_is_told_when_a_member_is_promoted_to_admin(): void Mail::assertQueued(AdminAccessGrantedMail::class, fn (AdminAccessGrantedMail $mail) => $mail->hasTo($this->owner->email)); } + public function test_owner_is_told_an_invited_admin_has_only_been_invited(): void + { + $admin = $this->addAccountUser($this->accountId, Role::ADMIN); + + $this->postJson('/users', [ + 'first_name' => 'New', + 'email' => 'new-admin-'.uniqid().'@test.com', + 'role' => Role::ADMIN->value, + ], $this->headersFor($admin, $this->accountId))->assertCreated(); + + Mail::assertQueued(AdminAccessGrantedMail::class, fn (AdminAccessGrantedMail $mail) => $mail->hasTo($this->owner->email) + && $mail->content()->with['isInvitation'] === true); + } + + public function test_promoting_an_invited_member_is_reported_as_an_invitation(): void + { + $admin = $this->addAccountUser($this->accountId, Role::ADMIN); + $member = $this->addAccountUser($this->accountId, Role::ORGANIZER, UserStatus::INVITED); + + $this->putJson("/users/{$member->id}", [ + 'first_name' => 'Promoted', + 'last_name' => 'Member', + 'role' => Role::ADMIN->value, + ], $this->headersFor($admin, $this->accountId))->assertOk(); + + Mail::assertQueued(AdminAccessGrantedMail::class, fn (AdminAccessGrantedMail $mail) => $mail->content()->with['isInvitation'] === true); + } + + public function test_promoting_an_active_member_is_reported_as_admin_access(): void + { + $admin = $this->addAccountUser($this->accountId, Role::ADMIN); + $member = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + $this->putJson("/users/{$member->id}", [ + 'first_name' => 'Promoted', + 'last_name' => 'Member', + 'role' => Role::ADMIN->value, + 'status' => 'ACTIVE', + ], $this->headersFor($admin, $this->accountId))->assertOk(); + + Mail::assertQueued(AdminAccessGrantedMail::class, fn (AdminAccessGrantedMail $mail) => $mail->content()->with['isInvitation'] === false); + } + private function subscribedMember(User $user): User { DB::table('account_users') diff --git a/backend/tests/Feature/OpenApi/IntegrationApiReferenceTest.php b/backend/tests/Feature/OpenApi/IntegrationApiReferenceTest.php index 22e0ff2a06..5977f7c1e0 100644 --- a/backend/tests/Feature/OpenApi/IntegrationApiReferenceTest.php +++ b/backend/tests/Feature/OpenApi/IntegrationApiReferenceTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\OpenApi; use HiEvents\DomainObjects\Enums\ApiKeyAccess; +use HiEvents\Enterprise\Licensing\LicensedFeature; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Process; use Tests\Concerns\ActsAsAccountUsers; @@ -51,6 +52,76 @@ public function test_signed_in_users_get_the_reference_for_api_key_endpoints_onl $this->assertSame(trim(file_get_contents(base_path('VERSION'))), $response->json('info.version')); } + public function test_empty_schemas_are_objects_not_arrays(): void + { + [$owner, $accountId] = $this->createAccountOwner(); + + $served = $this->getJson('/api-reference', $this->headersFor($owner, $accountId))->assertOk()->getContent(); + + $this->assertSame([], self::emptyArraySchemas(file_get_contents(base_path('resources/api-reference/openapi.json')))); + $this->assertSame([], self::emptyArraySchemas($served)); + $this->assertStringContainsString('"items":{}', $served); + } + + public function test_object_and_enum_fields_are_documented_with_their_real_types(): void + { + $schemas = json_decode(file_get_contents(base_path('resources/api-reference/openapi.json')), true)['components']['schemas']; + + $this->assertEqualsCanonicalizing( + array_column(LicensedFeature::cases(), 'value'), + $schemas['MeResource']['properties']['licence']['properties']['features_in_use']['items']['enum'], + ); + + $widget = $schemas['WidgetResource']['properties']; + $this->assertSame('object', $widget['settings']['type']); + $this->assertSame('object', $widget['stats']['properties']['revenue']['type']); + $this->assertSame('number', $widget['stats']['properties']['revenue']['additionalProperties']['type']); + + $this->assertSame( + [-1, 1, 2, 3, 4, null], + $schemas['GenerateOccurrencesRequest']['properties']['recurrence_rule']['properties']['week_position']['enum'], + ); + } + + public function test_every_operation_has_a_unique_operation_id(): void + { + $operationIds = array_column(self::operations(), 'operationId'); + + $this->assertCount(count(self::operations()), $operationIds); + $this->assertSame([], array_keys(array_filter(array_count_values($operationIds), static fn (int $count) => $count > 1))); + $this->assertContains('events.getEventPublicAction.getPublicEventsEventId', $operationIds); + $this->assertContains('events.getEventPublicAction.getPublicEventsEventIdProducts', $operationIds); + } + + public function test_paths_do_not_differ_only_by_parameter_names(): void + { + $paths = array_keys(self::committedReference()['paths']); + $shapes = array_map(static fn (string $path) => preg_replace('/\{[^}]+}/', '{}', $path), $paths); + + $this->assertSame([], array_keys(array_filter(array_count_values($shapes), static fn (int $count) => $count > 1))); + } + + public function test_every_request_body_is_documented(): void + { + $undocumented = array_keys(array_filter( + self::operations(), + static fn (array $operation) => str_contains($operation['description'] ?? '', 'Cannot generate request documentation'), + )); + + $this->assertSame([], $undocumented); + $this->assertArrayHasKey('requestBody', self::committedReference()['paths']['/events/{eventId}/questions']['post']); + } + + public function test_tuples_use_json_schema_2020_12_keywords(): void + { + $json = file_get_contents(base_path('resources/api-reference/openapi.json')); + + $this->assertStringNotContainsString('"additionalItems"', $json); + + $emptyTuple = self::committedReference()['components']['schemas']['CheckInListResourcePublic']['properties']['event_occurrences']['anyOf'][1]; + $this->assertSame(['type' => 'array', 'items' => false, 'minItems' => 0, 'maxItems' => 0], $emptyTuple); + } + public function test_the_reference_needs_a_signed_in_session(): void { [$owner, $accountId] = $this->createAccountOwner(); @@ -61,4 +132,38 @@ public function test_the_reference_needs_a_signed_in_session(): void ->assertForbidden() ->assertJsonPath('error_code', 'API_KEY_NOT_ALLOWED'); } + + /** + * @return arrayLost? call Jane on 555-0100
'], + ['id' => 'faq', 'type' => 'FAQ', 'enabled' => true, 'faqs' => [ + ['question' => 'Who runs this?', 'answer' => 'Jane Doe, jane@example.com
'], + ]], + ]), + 'website_url' => 'https://jane.example.com', + 'location_details' => json_encode(['address_line_1' => '1 Jane Street', 'city' => 'Dublin']), + 'social_media_handles' => json_encode(['instagram' => 'janedoe']), + 'seo_title' => 'Jane Doe Events', + 'seo_description' => 'Events run by Jane Doe from her home in Dublin', + 'seo_keywords' => 'jane doe, dublin', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/backend/tests/Feature/Services/Domain/Auth/ApiKey/ApiKeyExpiryReminderPendingDeletionTest.php b/backend/tests/Feature/Services/Domain/Auth/ApiKey/ApiKeyExpiryReminderPendingDeletionTest.php new file mode 100644 index 0000000000..3fd5a52987 --- /dev/null +++ b/backend/tests/Feature/Services/Domain/Auth/ApiKey/ApiKeyExpiryReminderPendingDeletionTest.php @@ -0,0 +1,78 @@ +createAccountOwner(); + $keyId = $this->dueKey($owner, $accountId); + $this->requestDeletion($owner, $accountId, AccountDeletionRequestStatus::REQUESTED); + + Mail::fake(); + (new SendApiKeyExpiryRemindersJob)->handle(app(ApiKeyExpiryReminderService::class)); + + Mail::assertNothingQueued(); + $this->assertNull(DB::table('personal_access_tokens')->where('id', $keyId)->value('expiry_reminder_sent_at')); + } + + public function test_still_reminds_after_a_cancelled_deletion_and_on_other_accounts(): void + { + [$owner, $accountId] = $this->createAccountOwner(); + $keyId = $this->dueKey($owner, $accountId); + $this->requestDeletion($owner, $accountId, AccountDeletionRequestStatus::CANCELLED); + + [$otherOwner, $otherAccountId] = $this->createAccountOwner(); + $this->requestDeletion($otherOwner, $otherAccountId, AccountDeletionRequestStatus::REQUESTED); + + Mail::fake(); + (new SendApiKeyExpiryRemindersJob)->handle(app(ApiKeyExpiryReminderService::class)); + + Mail::assertQueued(ApiKeyExpiringMail::class, fn (ApiKeyExpiringMail $mail) => $mail->hasTo($owner->email)); + $this->assertNotNull(DB::table('personal_access_tokens')->where('id', $keyId)->value('expiry_reminder_sent_at')); + } + + private function dueKey(User $owner, int $accountId): int + { + $this->createApiKey($owner, $accountId); + $keyId = DB::table('personal_access_tokens')->max('id'); + + DB::table('personal_access_tokens')->where('id', $keyId)->update([ + 'created_at' => now()->subDays(85), + 'expires_at' => now()->addDays(5), + ]); + + return $keyId; + } + + private function requestDeletion(User $owner, int $accountId, AccountDeletionRequestStatus $status): void + { + DB::table('account_deletion_requests')->insert([ + 'account_id' => $accountId, + 'requested_by_user_id' => $owner->id, + 'initiated_by' => 'USER', + 'status' => $status->name, + 'expected_outcome' => 'HARD_DELETE', + 'scheduled_deletion_at' => now()->addDays(30), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/backend/tests/Feature/Services/Domain/Mail/TeamOrderNotificationRecipientServiceTest.php b/backend/tests/Feature/Services/Domain/Mail/TeamOrderNotificationRecipientServiceTest.php new file mode 100644 index 0000000000..55846ce712 --- /dev/null +++ b/backend/tests/Feature/Services/Domain/Mail/TeamOrderNotificationRecipientServiceTest.php @@ -0,0 +1,79 @@ +accountId] = $this->createAccountOwner(); + $this->eventId = $this->createEvent($this->accountId, $this->createOrganizer($this->accountId)); + } + + public function test_recipients_carry_each_members_locale(): void + { + $french = $this->subscribedMember('fr'); + $english = $this->subscribedMember('en'); + + $locales = collect($this->recipientsFor(null)) + ->mapWithKeys(fn (UserDomainObject $user) => [strtolower($user->getEmail()) => $user->getLocale()]) + ->sortKeys() + ->all(); + + $expected = collect([ + strtolower($french->email) => 'fr', + strtolower($english->email) => 'en', + ])->sortKeys()->all(); + + $this->assertSame($expected, $locales); + } + + public function test_the_excluded_address_is_skipped_regardless_of_case(): void + { + $member = $this->subscribedMember('fr'); + + $this->assertSame([], $this->recipientsFor(strtoupper($member->email))); + } + + /** + * @return UserDomainObject[] + */ + private function recipientsFor(?string $excludeEmail): array + { + return $this->app->make(TeamOrderNotificationRecipientService::class)->recipientsFor( + $this->app->make(EventRepositoryInterface::class)->findById($this->eventId), + $excludeEmail, + ); + } + + private function subscribedMember(string $locale): User + { + $user = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + DB::table('users')->where('id', $user->id)->update(['locale' => $locale]); + DB::table('account_users') + ->where('id', $this->accountUserId($user, $this->accountId)) + ->update(['notify_new_orders' => true]); + + return $user; + } +} diff --git a/backend/tests/Feature/Services/Infrastructure/Webhook/WebhookDeliveryRetryTest.php b/backend/tests/Feature/Services/Infrastructure/Webhook/WebhookDeliveryRetryTest.php new file mode 100644 index 0000000000..d2e18a760e --- /dev/null +++ b/backend/tests/Feature/Services/Infrastructure/Webhook/WebhookDeliveryRetryTest.php @@ -0,0 +1,171 @@ + */ + private array $sentRequests = []; + + protected function setUp(): void + { + parent::setUp(); + + [$owner, $accountId] = $this->createAccountOwner(); + $eventId = $this->createEvent($accountId, $this->createOrganizer($accountId)); + + $this->orderId = DB::table('orders')->insertGetId([ + 'short_id' => 'ord_'.uniqid(), + 'event_id' => $eventId, + 'currency' => 'USD', + 'status' => 'COMPLETED', + 'public_id' => 'PUB_'.uniqid(), + 'first_name' => 'Ada', + 'last_name' => 'Buyer', + 'email' => 'ada@example.test', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->webhookId = DB::table('webhooks')->insertGetId([ + 'url' => 'http://127.0.0.1/hook', + 'event_types' => json_encode([DomainEventType::ORDER_UPDATED->value]), + 'status' => 'ENABLED', + 'secret' => 'test-secret', + 'user_id' => $owner->id, + 'account_id' => $accountId, + 'event_id' => $eventId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Config::set('app.allowed_internal_webhook_hosts', '127.0.0.1'); + Config::set('queue.webhook_queue_name', self::WEBHOOK_QUEUE); + } + + public function test_each_delivery_is_queued_on_the_webhook_queue_with_retries(): void + { + Queue::fake(); + + app(WebhookDispatchService::class)->dispatchOrderWebhook(DomainEventType::ORDER_UPDATED, $this->orderId); + + Queue::assertPushedOn( + self::WEBHOOK_QUEUE, + SecureCallWebhookJob::class, + fn (SecureCallWebhookJob $job) => $job->connection === null && $job->tries === 3, + ); + } + + public function test_a_failed_delivery_is_retried_and_every_attempt_is_logged(): void + { + $this->fakeResponses([new Response(500, [], 'down'), new Response(200, [], 'ok')]); + $job = $this->queuedDelivery(); + + $this->attempt($job, 1)->assertReleased(10); + $this->attempt($job, 2)->assertNotReleased(); + + $this->assertCount(2, $this->sentRequests); + $first = $this->sentRequests[0]['request']; + $retry = $this->sentRequests[1]['request']; + $this->assertSame((string) $first->getBody(), (string) $retry->getBody()); + $this->assertSame($first->getHeaderLine('Signature'), $retry->getHeaderLine('Signature')); + + $this->assertSame([500, 200], $this->loggedResponseCodes()); + $this->assertSame(200, DB::table('webhooks')->where('id', $this->webhookId)->value('last_response_code')); + } + + public function test_the_last_failed_attempt_gives_up(): void + { + $this->fakeResponses([new ConnectException('refused', new Request('POST', 'http://127.0.0.1/hook'))]); + $job = $this->queuedDelivery(); + + $attempt = $this->attempt($job, 3); + + $attempt->assertNotReleased(); + $attempt->assertDeleted(); + $this->assertSame([0], $this->loggedResponseCodes()); + } + + public function test_a_payload_too_large_for_the_queue_is_delivered_inline_instead(): void + { + Queue::fake(); + DB::table('orders')->where('id', $this->orderId)->update(['notes' => str_repeat('n', 160 * 1024)]); + + app(WebhookDispatchService::class)->dispatchOrderWebhook(DomainEventType::ORDER_UPDATED, $this->orderId); + + $pushed = Queue::pushed(SecureCallWebhookJob::class); + $this->assertCount(1, $pushed); + $job = $pushed->first(); + $this->assertSame('sync', $job->connection); + $this->assertNotSame(self::WEBHOOK_QUEUE, $job->queue); + $this->assertSame(160 * 1024, strlen($job->payload['payload']->notes)); + } + + private function queuedDelivery(): SecureCallWebhookJob + { + Queue::fake(); + + app(WebhookDispatchService::class)->dispatchOrderWebhook(DomainEventType::ORDER_UPDATED, $this->orderId); + + $pushed = Queue::pushed(SecureCallWebhookJob::class); + $this->assertCount(1, $pushed); + + return $pushed->first(); + } + + private function attempt(SecureCallWebhookJob $job, int $attempt): SecureCallWebhookJob + { + /** @var SecureCallWebhookJob $worker */ + $worker = unserialize(serialize($job)); + $worker->withFakeQueueInteractions(); + $worker->job->attempts = $attempt; + + $worker->handle(); + + return $worker; + } + + private function fakeResponses(array $responses): void + { + $handler = HandlerStack::create(new MockHandler($responses)); + $handler->push(Middleware::history($this->sentRequests)); + $this->app->instance(Client::class, new Client(['handler' => $handler])); + } + + private function loggedResponseCodes(): array + { + return DB::table('webhook_logs') + ->where('webhook_id', $this->webhookId) + ->orderBy('id') + ->pluck('response_code') + ->map(fn ($code) => (int) $code) + ->all(); + } +} diff --git a/backend/tests/Feature/Services/Infrastructure/Webhook/WebhookDispatchServiceTest.php b/backend/tests/Feature/Services/Infrastructure/Webhook/WebhookDispatchServiceTest.php new file mode 100644 index 0000000000..cea473b37f --- /dev/null +++ b/backend/tests/Feature/Services/Infrastructure/Webhook/WebhookDispatchServiceTest.php @@ -0,0 +1,174 @@ + 25.5, + 'price_before_discount' => 30, + 'total_before_additions' => 51, + ]; + + private int $accountId; + + private int $eventId; + + private int $orderId; + + private string $orderShortId; + + /** @var arrayassertStringNotContainsString('', strpos($html, 'class="panel-item"'));
+ $button = strpos($html, 'class="action"');
+
+ $this->assertNotFalse($panelStart);
+ $this->assertStringContainsString('Zapier sync', substr($html, $panelStart, $panelItemEnd - $panelStart));
+ $this->assertGreaterThan($panelItemEnd, $button);
+ $this->assertSame(
+ substr_count($html, ''),
+ );
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/ApproveMessageHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/ApproveMessageHandlerTest.php
new file mode 100644
index 0000000000..05afa7c354
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Admin/ApproveMessageHandlerTest.php
@@ -0,0 +1,103 @@
+messageRepository = Mockery::mock(MessageRepositoryInterface::class);
+
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn (callable $callback) => $callback());
+
+ $this->handler = new ApproveMessageHandler(
+ $this->messageRepository,
+ $databaseManager,
+ new MessageDispatchService(
+ $this->messageRepository,
+ Mockery::mock(EventOccurrenceRepositoryInterface::class),
+ ),
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_approved_test_message_is_sent_now_as_a_test(): void
+ {
+ $message = $this->makeHeldMessage(scheduledAt: null, isTest: true);
+
+ $this->messageRepository->shouldReceive('findFirst')->with(1)->andReturn($message);
+ $this->messageRepository->shouldNotReceive('updateFromArray');
+ $this->messageRepository->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ ['status' => MessageStatus::PROCESSING->name],
+ ['id' => 1, 'status' => MessageStatus::PENDING_REVIEW->name],
+ )
+ ->andReturn(1);
+
+ $this->handler->handle(1);
+
+ Bus::assertDispatched(SendMessagesJob::class, fn (SendMessagesJob $job) => $job->messageData->is_test === true);
+ }
+
+ public function test_approved_live_message_with_a_future_schedule_is_scheduled(): void
+ {
+ $message = $this->makeHeldMessage(scheduledAt: Carbon::now()->addDay()->toDateTimeString(), isTest: false);
+
+ $this->messageRepository->shouldReceive('findFirst')->with(1)->andReturn($message);
+ $this->messageRepository->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, ['status' => MessageStatus::SCHEDULED->name])
+ ->andReturn($message);
+
+ $this->handler->handle(1);
+
+ Bus::assertNotDispatched(SendMessagesJob::class);
+ }
+
+ private function makeHeldMessage(?string $scheduledAt, bool $isTest): MessageDomainObject
+ {
+ return (new MessageDomainObject)
+ ->setId(1)
+ ->setEventId(10)
+ ->setSubject('Subject')
+ ->setMessage('Body')
+ ->setType('ALL_ATTENDEES')
+ ->setSentByUserId(5)
+ ->setStatus(MessageStatus::PENDING_REVIEW->name)
+ ->setScheduledAt($scheduledAt)
+ ->setSendData([
+ 'account_id' => 99,
+ 'is_test' => $isTest,
+ 'send_copy_to_current_user' => false,
+ ]);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php
index 7a7aa7c709..3a931cf153 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php
@@ -8,13 +8,18 @@
use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\Enterprise\Seating\Services\Domain\SeatClaimService;
use HiEvents\Enterprise\Seating\Services\Domain\SeatedProductLookupService;
+use HiEvents\Enterprise\Seating\Services\Domain\SeatingEventLockService;
use HiEvents\Events\CapacityChangedEvent;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Services\Application\Handlers\Attendee\DTO\PartialEditAttendeeDTO;
use HiEvents\Services\Application\Handlers\Attendee\PartialEditAttendeeHandler;
+use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService;
use HiEvents\Services\Domain\EventStatistics\EventStatisticsReactivationService;
+use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
+use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
+use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
@@ -62,6 +67,12 @@ class PartialEditAttendeeHandlerTest extends TestCase
private SeatedProductLookupService|MockInterface $seatedProductLookupService;
+ private SeatingEventLockService|MockInterface $seatingEventLock;
+
+ private OccurrencePurchaseEligibilityService|MockInterface $occurrenceEligibilityService;
+
+ private AvailableProductQuantitiesFetchService|MockInterface $availableProductQuantitiesFetchService;
+
protected function setUp(): void
{
parent::setUp();
@@ -84,6 +95,12 @@ protected function setUp(): void
$this->seatedProductLookupService = Mockery::mock(SeatedProductLookupService::class);
$this->seatedProductLookupService->shouldReceive('isSeated')->andReturn(false)->byDefault();
+ $this->seatingEventLock = Mockery::mock(SeatingEventLockService::class);
+ $this->seatingEventLock->shouldReceive('lock')->with(self::EVENT_ID)->byDefault();
+
+ $this->occurrenceEligibilityService = Mockery::mock(OccurrencePurchaseEligibilityService::class);
+ $this->availableProductQuantitiesFetchService = Mockery::mock(AvailableProductQuantitiesFetchService::class);
+
$this->handler = new PartialEditAttendeeHandler(
$this->attendeeRepository,
$this->orderRepository,
@@ -95,6 +112,9 @@ protected function setUp(): void
Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(),
$this->seatClaimService,
$this->seatedProductLookupService,
+ $this->seatingEventLock,
+ $this->occurrenceEligibilityService,
+ $this->availableProductQuantitiesFetchService,
);
}
@@ -136,6 +156,7 @@ public function test_reactivating_a_cancelled_attendee_consumes_capacity_and_inc
{
$this->givenAttendee(AttendeeStatus::CANCELLED);
$this->givenOrderExists();
+ $this->givenRoomToReactivate();
$this->expectAttendeeUpdatedWithStatus(AttendeeStatus::ACTIVE);
$this->productQuantityService->shouldReceive('increaseQuantitySold')
@@ -313,6 +334,124 @@ public function test_capacity_is_still_released_when_the_order_cannot_be_found()
$this->handler->handle($this->dto(AttendeeStatus::CANCELLED));
}
+ public function test_the_event_is_locked_before_the_attendee_is_read(): void
+ {
+ $this->seatingEventLock->shouldReceive('lock')->once()->with(self::EVENT_ID)->globally()->ordered();
+ $this->attendeeRepository->shouldReceive('findFirstWhere')
+ ->once()
+ ->globally()
+ ->ordered()
+ ->andReturn($this->attendee(AttendeeStatus::ACTIVE));
+ $this->expectAttendeeUpdatedWithStatus(AttendeeStatus::ACTIVE);
+
+ $this->handler->handle($this->dto(AttendeeStatus::ACTIVE));
+ }
+
+ public function test_editing_only_contact_details_does_not_lock_the_event(): void
+ {
+ $this->givenAttendee(AttendeeStatus::ACTIVE);
+ $this->attendeeRepository->shouldReceive('updateByIdWhere')->once()->andReturn(new AttendeeDomainObject);
+ $this->seatingEventLock->shouldNotReceive('lock');
+
+ $this->handler->handle(new PartialEditAttendeeDTO(
+ attendee_id: self::ATTENDEE_ID,
+ event_id: self::EVENT_ID,
+ first_name: 'Renamed',
+ last_name: null,
+ email: null,
+ status: null,
+ ));
+ }
+
+ public function test_a_cancelled_attendee_is_not_reactivated_when_their_price_has_no_room(): void
+ {
+ $this->givenAttendee(AttendeeStatus::CANCELLED);
+ $this->givenRoomToReactivate(quantityAvailable: 0);
+
+ $this->productQuantityService->shouldNotReceive('increaseQuantitySold');
+ $this->reactivationService->shouldNotReceive('incrementForReactivatedAttendee');
+ $this->attendeeRepository->shouldNotReceive('updateByIdWhere');
+
+ $this->assertStatusRefused(AttendeeStatus::ACTIVE);
+
+ Event::assertNotDispatched(CapacityChangedEvent::class);
+ }
+
+ public function test_a_cancelled_attendee_is_not_reactivated_when_the_price_row_is_gone(): void
+ {
+ $this->givenAttendee(AttendeeStatus::CANCELLED);
+ $this->occurrenceEligibilityService->shouldReceive('assertOccurrencePurchasable');
+ $this->availableProductQuantitiesFetchService->shouldReceive('getAvailableProductQuantities')
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(productQuantities: collect()));
+
+ $this->productQuantityService->shouldNotReceive('increaseQuantitySold');
+ $this->attendeeRepository->shouldNotReceive('updateByIdWhere');
+
+ $this->assertStatusRefused(AttendeeStatus::ACTIVE);
+ }
+
+ public function test_a_full_date_is_reported_against_the_status_field(): void
+ {
+ $this->givenAttendee(AttendeeStatus::CANCELLED);
+ $this->occurrenceEligibilityService->shouldReceive('assertOccurrencePurchasable')
+ ->once()
+ ->with(self::EVENT_ID, self::OCCURRENCE_ID, 1, false, null, null, true)
+ ->andThrow(ValidationException::withMessages(['event_occurrence_id' => 'This event is sold out']));
+ $this->availableProductQuantitiesFetchService->shouldNotReceive('getAvailableProductQuantities');
+
+ $this->productQuantityService->shouldNotReceive('increaseQuantitySold');
+ $this->attendeeRepository->shouldNotReceive('updateByIdWhere');
+
+ $this->assertSame('This event is sold out', $this->assertStatusRefused(AttendeeStatus::ACTIVE));
+ }
+
+ public function test_moving_from_awaiting_payment_to_active_does_not_touch_capacity_or_statistics(): void
+ {
+ $this->givenAttendee(AttendeeStatus::AWAITING_PAYMENT);
+ $this->expectAttendeeUpdatedWithStatus(AttendeeStatus::ACTIVE);
+
+ $this->occurrenceEligibilityService->shouldNotReceive('assertOccurrencePurchasable');
+ $this->availableProductQuantitiesFetchService->shouldNotReceive('getAvailableProductQuantities');
+ $this->seatClaimService->shouldNotReceive('reclaimForAttendee');
+ $this->productQuantityService->shouldNotReceive('increaseQuantitySold');
+ $this->productQuantityService->shouldNotReceive('decreaseQuantitySold');
+ $this->reactivationService->shouldNotReceive('incrementForReactivatedAttendee');
+ $this->cancellationService->shouldNotReceive('decrementForCancelledAttendee');
+ $this->domainEventDispatcherService->shouldNotReceive('dispatch');
+
+ $this->handler->handle($this->dto(AttendeeStatus::ACTIVE));
+
+ Event::assertNotDispatched(CapacityChangedEvent::class);
+ }
+
+ public function test_bringing_a_cancelled_attendee_back_as_awaiting_payment_is_checked_and_counted(): void
+ {
+ $this->givenAttendee(AttendeeStatus::CANCELLED);
+ $this->givenOrderExists();
+ $this->givenRoomToReactivate();
+ $this->expectAttendeeUpdatedWithStatus(AttendeeStatus::AWAITING_PAYMENT);
+
+ $this->productQuantityService->shouldReceive('increaseQuantitySold')
+ ->once()
+ ->with(self::PRODUCT_PRICE_ID, 1, self::OCCURRENCE_ID);
+ $this->reactivationService->shouldReceive('incrementForReactivatedAttendee')
+ ->once()
+ ->with(self::EVENT_ID, self::ORDER_CREATED_AT, 1, self::OCCURRENCE_ID);
+
+ $this->handler->handle($this->dto(AttendeeStatus::AWAITING_PAYMENT));
+ }
+
+ public function test_a_cancelled_attendee_without_a_seat_cannot_come_back_awaiting_payment_once_the_ticket_is_seated(): void
+ {
+ $this->givenAttendee(AttendeeStatus::CANCELLED);
+ $this->seatedProductLookupService->shouldReceive('isSeated')->once()->with(self::PRODUCT_ID)->andReturn(true);
+
+ $this->productQuantityService->shouldNotReceive('increaseQuantitySold');
+ $this->attendeeRepository->shouldNotReceive('updateByIdWhere');
+
+ $this->assertStatusRefused(AttendeeStatus::AWAITING_PAYMENT);
+ }
+
public function test_throws_when_attendee_does_not_belong_to_event(): void
{
$this->attendeeRepository->shouldReceive('findFirstWhere')
@@ -337,9 +476,53 @@ private function dto(AttendeeStatus $status): PartialEditAttendeeDTO
);
}
+ private function assertStatusRefused(AttendeeStatus $status): string
+ {
+ try {
+ $this->handler->handle($this->dto($status));
+ } catch (ValidationException $exception) {
+ $this->assertArrayHasKey('status', $exception->errors());
+
+ return $exception->errors()['status'][0];
+ }
+
+ $this->fail('Expected the status change to be refused');
+ }
+
+ private function givenRoomToReactivate(int $quantityAvailable = 5): void
+ {
+ $this->occurrenceEligibilityService->shouldReceive('assertOccurrencePurchasable')
+ ->once()
+ ->with(self::EVENT_ID, self::OCCURRENCE_ID, 1, false, null, null, true);
+
+ $this->availableProductQuantitiesFetchService->shouldReceive('getAvailableProductQuantities')
+ ->once()
+ ->with(self::EVENT_ID, true, self::OCCURRENCE_ID, false)
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(
+ productQuantities: collect([new AvailableProductQuantitiesDTO(
+ product_id: self::PRODUCT_ID,
+ price_id: self::PRODUCT_PRICE_ID,
+ product_title: 'General admission',
+ price_label: null,
+ quantity_available: $quantityAvailable,
+ quantity_reserved: 0,
+ initial_quantity_available: 10,
+ product_type: 'TICKET',
+ )]),
+ ));
+ }
+
private function givenAttendee(AttendeeStatus $status, ?int $occurrenceId = self::OCCURRENCE_ID): void
{
- $attendee = (new AttendeeDomainObject)
+ $this->attendeeRepository->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => self::ATTENDEE_ID, 'event_id' => self::EVENT_ID])
+ ->andReturn($this->attendee($status, $occurrenceId));
+ }
+
+ private function attendee(AttendeeStatus $status, ?int $occurrenceId = self::OCCURRENCE_ID): AttendeeDomainObject
+ {
+ return (new AttendeeDomainObject)
->setId(self::ATTENDEE_ID)
->setEventId(self::EVENT_ID)
->setOrderId(self::ORDER_ID)
@@ -350,11 +533,6 @@ private function givenAttendee(AttendeeStatus $status, ?int $occurrenceId = self
->setFirstName('Original')
->setLastName('Last')
->setEmail('original@example.com');
-
- $this->attendeeRepository->shouldReceive('findFirstWhere')
- ->once()
- ->with(['id' => self::ATTENDEE_ID, 'event_id' => self::EVENT_ID])
- ->andReturn($attendee);
}
private function givenOrderExists(): void
diff --git a/backend/tests/Unit/Services/Application/Handlers/Lineup/GetEventLineupHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Lineup/GetEventLineupHandlerTest.php
index 1ef40c9fa6..b0a81004ad 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Lineup/GetEventLineupHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Lineup/GetEventLineupHandlerTest.php
@@ -2,6 +2,7 @@
namespace Tests\Unit\Services\Application\Handlers\Lineup;
+use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventLineupEntryDomainObject;
use HiEvents\DomainObjects\EventScheduleItemDomainObject;
use HiEvents\DomainObjects\EventScheduleItemPerformerDomainObject;
@@ -10,6 +11,7 @@
use HiEvents\DomainObjects\ProfileDomainObject;
use HiEvents\Repository\Interfaces\EventLineupEntryRepositoryInterface;
use HiEvents\Repository\Interfaces\EventLocationRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\EventScheduleItemRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSpaceRepositoryInterface;
@@ -49,7 +51,10 @@ public function test_public_lineup_hides_hidden_entries_and_their_untitled_slots
$settings = m::mock(EventSettingsRepositoryInterface::class);
$settings->shouldReceive('findFirstWhere')->andReturn(null);
- $lineup = (new GetEventLineupHandler($lineupEntries, $spaces, $items, $venues, $settings))->handle(5, forPublic: true);
+ $events = m::mock(EventRepositoryInterface::class);
+ $events->shouldNotReceive('findById');
+
+ $lineup = (new GetEventLineupHandler($lineupEntries, $spaces, $items, $venues, $settings, $events))->handle(5, forPublic: true);
$this->assertSame([1], $lineup->entries->map->getId()->all());
$this->assertSame([10], $lineup->schedule_items->map->getId()->all());
@@ -58,6 +63,7 @@ public function test_public_lineup_hides_hidden_entries_and_their_untitled_slots
$lineup->schedule_items->first()->getPerformers()->map->getEventLineupEntryId()->all(),
);
$this->assertNull($lineup->venues);
+ $this->assertNull($lineup->schedule_days_by_entry_id);
}
public function test_public_lineup_follows_the_homepage_section_toggles(): void
@@ -65,9 +71,13 @@ public function test_public_lineup_follows_the_homepage_section_toggles(): void
$scheduled = $this->entry(1, hidden: false);
$unscheduled = $this->entry(2, hidden: false);
$slot = (new EventScheduleItemDomainObject)->setId(10)->setTitle(null)
+ ->setStartsAt('2030-06-15 03:00:00')
+ ->setPerformers(new Collection([$this->performer(1)]));
+ $lateSlot = (new EventScheduleItemDomainObject)->setId(11)->setTitle(null)
+ ->setStartsAt('2030-06-22 07:30:00')
->setPerformers(new Collection([$this->performer(1)]));
- $handler = function (?array $sections) use ($scheduled, $unscheduled, $slot) {
+ $handler = function (?array $sections, string $type = 'RECURRING') use ($scheduled, $unscheduled, $slot, $lateSlot) {
$lineupEntries = m::mock(EventLineupEntryRepositoryInterface::class);
$lineupEntries->shouldReceive('loadRelation')->andReturnSelf();
$lineupEntries->shouldReceive('findWhere')->andReturn(new Collection([$scheduled, $unscheduled]));
@@ -76,11 +86,14 @@ public function test_public_lineup_follows_the_homepage_section_toggles(): void
$spaces->shouldReceive('findWhere')->andReturn(new Collection([(new EventSpaceDomainObject)->setId(3)]));
$items = m::mock(EventScheduleItemRepositoryInterface::class);
$items->shouldReceive('loadRelation')->andReturnSelf();
- $items->shouldReceive('findWhere')->andReturn(new Collection([clone $slot]));
+ $items->shouldReceive('findWhere')->andReturn(new Collection([clone $slot, clone $lateSlot]));
$settings = m::mock(EventSettingsRepositoryInterface::class);
$settings->shouldReceive('findFirstWhere')->andReturn((new EventSettingDomainObject)->setHomepageSections($sections));
- return (new GetEventLineupHandler($lineupEntries, $spaces, $items, m::mock(EventLocationRepositoryInterface::class), $settings))
+ $events = m::mock(EventRepositoryInterface::class);
+ $events->shouldReceive('findById')->with(5)->andReturn((new EventDomainObject)->setTimezone('America/New_York')->setType($type));
+
+ return (new GetEventLineupHandler($lineupEntries, $spaces, $items, m::mock(EventLocationRepositoryInterface::class), $settings, $events))
->handle(5, forPublic: true);
};
@@ -88,10 +101,17 @@ public function test_public_lineup_follows_the_homepage_section_toggles(): void
$this->assertSame([1, 2], $scheduleOff->entries->map->getId()->all());
$this->assertCount(0, $scheduleOff->schedule_items);
$this->assertCount(0, $scheduleOff->spaces);
+ $this->assertSame([1 => ['2030-06-14', '2030-06-21'], 2 => []], $scheduleOff->schedule_days_by_entry_id);
+
+ $singleScheduleOff = $handler([['id' => 'schedule', 'type' => 'SCHEDULE', 'enabled' => false]], 'SINGLE');
+ $this->assertSame([1, 2], $singleScheduleOff->entries->map->getId()->all());
+ $this->assertCount(0, $singleScheduleOff->schedule_items);
+ $this->assertNull($singleScheduleOff->schedule_days_by_entry_id);
$lineupOff = $handler([['id' => 'lineup', 'type' => 'LINEUP', 'enabled' => false]]);
$this->assertSame([1], $lineupOff->entries->map->getId()->all());
- $this->assertCount(1, $lineupOff->schedule_items);
+ $this->assertCount(2, $lineupOff->schedule_items);
+ $this->assertNull($lineupOff->schedule_days_by_entry_id);
$bothOff = $handler([
['id' => 'lineup', 'type' => 'LINEUP', 'enabled' => false],
@@ -99,6 +119,7 @@ public function test_public_lineup_follows_the_homepage_section_toggles(): void
]);
$this->assertCount(0, $bothOff->entries);
$this->assertCount(0, $bothOff->schedule_items);
+ $this->assertSame([], $bothOff->schedule_days_by_entry_id);
}
private function entry(int $id, bool $hidden): EventLineupEntryDomainObject
diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php
index d58095ecd7..52009273b2 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php
@@ -6,12 +6,14 @@
use HiEvents\DomainObjects\AccountDomainObject;
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\Enums\MessageTypeEnum;
+use HiEvents\DomainObjects\Enums\MessagingEligibilityFailureEnum;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\MessageDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\Status\MessageStatus;
use HiEvents\Jobs\Event\SendMessagesJob;
+use HiEvents\Jobs\Message\MessagePendingReviewJob;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
@@ -20,6 +22,7 @@
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO;
use HiEvents\Services\Application\Handlers\Message\SendMessageHandler;
+use HiEvents\Services\Domain\Message\DTO\MessagingEligibilityFailureDTO;
use HiEvents\Services\Domain\Message\MessagingEligibilityService;
use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
use Illuminate\Config\Repository;
@@ -76,7 +79,7 @@ protected function setUp(): void
);
}
- private function setupAccountMocks(): void
+ private function setupAccountMocks(?MessagingEligibilityFailureDTO $eligibilityFailure = null): void
{
$account = m::mock(AccountDomainObject::class);
$account->shouldReceive('getAccountVerifiedAt')->andReturn(Carbon::now());
@@ -90,7 +93,7 @@ private function setupAccountMocks(): void
$this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false);
$this->eligibilityService->shouldReceive('checkTierLimits')->andReturn(null);
- $this->eligibilityService->shouldReceive('checkEligibility')->andReturn(null);
+ $this->eligibilityService->shouldReceive('checkEligibility')->andReturn($eligibilityFailure);
$this->purifier->shouldReceive('purify')->andReturn('Test
');
}
@@ -107,6 +110,7 @@ private function setupRepositoryMocks(): void
$order->setId(5);
$this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect([$attendee]));
+ $this->attendeeRepository->shouldReceive('countWhere')->andReturn(1);
$this->productRepository->shouldReceive('findWhereIn')->andReturn(collect([$product]));
$this->orderRepository->shouldReceive('findFirstWhere')->andReturn($order);
}
@@ -242,4 +246,96 @@ public function test_is_test_with_scheduled_at_sends_immediately(): void
$this->assertSame($message, $result);
Bus::assertDispatched(SendMessagesJob::class);
}
+
+ public function test_is_test_with_scheduled_at_stores_no_schedule(): void
+ {
+ Bus::fake();
+
+ $this->setupAccountMocks();
+ $this->setupRepositoryMocks();
+
+ $this->messageRepository->shouldReceive('create')
+ ->once()
+ ->withArgs(fn ($data) => $data['status'] === MessageStatus::PROCESSING->name
+ && $data['scheduled_at'] === null
+ && $data['send_data']['is_test'] === true)
+ ->andReturn($this->makeCreatedMessage());
+
+ $this->handler->handle($this->makeTestDto(
+ Carbon::now('America/New_York')->addHour()->format('Y-m-d\TH:i'),
+ ));
+
+ Bus::assertDispatched(SendMessagesJob::class, fn (SendMessagesJob $job) => $job->messageData->is_test === true);
+ }
+
+ public function test_is_test_with_past_scheduled_at_is_not_rejected(): void
+ {
+ Bus::fake();
+
+ $this->setupAccountMocks();
+ $this->setupRepositoryMocks();
+
+ $this->messageRepository->shouldReceive('create')
+ ->once()
+ ->withArgs(fn ($data) => $data['scheduled_at'] === null)
+ ->andReturn($this->makeCreatedMessage());
+
+ $this->handler->handle($this->makeTestDto(
+ Carbon::now('America/New_York')->subHour()->format('Y-m-d\TH:i'),
+ ));
+
+ Bus::assertDispatched(SendMessagesJob::class);
+ }
+
+ public function test_scheduled_test_held_for_review_is_pending_without_schedule(): void
+ {
+ Bus::fake();
+
+ $this->setupAccountMocks(new MessagingEligibilityFailureDTO(
+ accountId: 1,
+ eventId: 101,
+ failures: [MessagingEligibilityFailureEnum::STRIPE_NOT_CONNECTED],
+ ));
+ $this->setupRepositoryMocks();
+
+ $this->messageRepository->shouldReceive('create')
+ ->once()
+ ->withArgs(fn ($data) => $data['status'] === MessageStatus::PENDING_REVIEW->name
+ && $data['scheduled_at'] === null)
+ ->andReturn($this->makeCreatedMessage());
+
+ $this->handler->handle($this->makeTestDto(
+ Carbon::now('America/New_York')->addDays(2)->format('Y-m-d\TH:i'),
+ ));
+
+ Bus::assertNotDispatched(SendMessagesJob::class);
+ Bus::assertDispatched(MessagePendingReviewJob::class);
+ }
+
+ private function makeCreatedMessage(): MessageDomainObject
+ {
+ $message = m::mock(MessageDomainObject::class);
+ $message->shouldReceive('getId')->andReturn(1);
+ $message->shouldReceive('getOrderId')->andReturn(5);
+ $message->shouldReceive('getAttendeeIds')->andReturn([10]);
+ $message->shouldReceive('getProductIds')->andReturn([20]);
+
+ return $message;
+ }
+
+ private function makeTestDto(string $scheduledAt): SendMessageDTO
+ {
+ return new SendMessageDTO(
+ account_id: 1,
+ event_id: 101,
+ subject: 'Hello',
+ message: 'Test
',
+ type: MessageTypeEnum::ALL_ATTENDEES,
+ is_test: true,
+ send_copy_to_current_user: false,
+ sent_by_user_id: 99,
+ order_statuses: [],
+ scheduled_at: $scheduledAt,
+ );
+ }
}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandlerTest.php
index f352ca14ce..56daf826fe 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandlerTest.php
@@ -2,22 +2,19 @@
namespace Tests\Unit\Services\Application\Handlers\Order\Payment\Stripe;
-use HiEvents\DomainObjects\EventDomainObject;
-use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
-use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\Status\OrderRefundStatus;
use HiEvents\DomainObjects\StripePaymentDomainObject;
-use HiEvents\Mail\Order\OrderRefunded;
-use HiEvents\Repository\Interfaces\EventRepositoryInterface;
+use HiEvents\Exceptions\RefundNotPossibleException;
+use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Services\Application\Handlers\Order\DTO\RefundOrderDTO;
use HiEvents\Services\Application\Handlers\Order\Payment\Stripe\RefundOrderHandler;
use HiEvents\Services\Domain\Order\OrderCancelService;
use HiEvents\Services\Domain\Payment\Stripe\StripePaymentIntentRefundService;
use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory;
-use Illuminate\Contracts\Mail\Mailer;
+use HiEvents\Values\MoneyValue;
use Illuminate\Database\DatabaseManager;
use Mockery;
use Mockery\MockInterface;
@@ -33,25 +30,22 @@ class RefundOrderHandlerTest extends TestCase
private OrderRepositoryInterface|MockInterface $orderRepository;
- private EventRepositoryInterface|MockInterface $eventRepository;
-
- private Mailer|MockInterface $mailer;
+ private OrderRefundRepositoryInterface|MockInterface $orderRefundRepository;
private StripePaymentIntentRefundService|MockInterface $refundService;
private RefundOrderHandler $handler;
+ private array $refundRequest = [];
+
protected function setUp(): void
{
parent::setUp();
$this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
- $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
- $this->mailer = Mockery::mock(Mailer::class);
+ $this->orderRefundRepository = Mockery::mock(OrderRefundRepositoryInterface::class);
$this->refundService = Mockery::mock(StripePaymentIntentRefundService::class);
- $orderCancelService = Mockery::mock(OrderCancelService::class);
-
$stripeClientFactory = Mockery::mock(StripeClientFactory::class);
$stripeClientFactory->shouldReceive('createForPlatform')->andReturn(Mockery::mock(StripeClient::class));
@@ -61,52 +55,97 @@ protected function setUp(): void
$this->handler = new RefundOrderHandler(
$this->refundService,
$this->orderRepository,
- $this->eventRepository,
- $this->mailer,
- $orderCancelService,
+ $this->orderRefundRepository,
+ Mockery::mock(OrderCancelService::class),
$databaseManager,
$stripeClientFactory,
);
}
- public function test_the_buyer_is_notified_when_requested(): void
+ public function test_the_buyer_is_flagged_for_the_refund_email_instead_of_being_emailed_straight_away(): void
{
$order = $this->givenOrderIsFound(email: 'buyer@example.com');
- $this->givenEventIsFound();
- $this->givenRefundSucceeds();
-
- $this->mailer->shouldReceive('to')->once()->with('buyer@example.com')->andReturnSelf();
- $this->mailer->shouldReceive('locale')->once()->andReturnSelf();
- $this->mailer->shouldReceive('send')->once()->with(Mockery::type(OrderRefunded::class));
+ $this->givenFailedRefunds(0);
+ $this->givenStripeAcceptsTheRefund(Refund::constructFrom(['id' => 're_1', 'status' => 'pending']));
$this->assertSame($order, $this->handler->handle($this->givenDTO(notifyBuyer: true)));
+
+ $this->assertTrue($this->refundRequest['notifyBuyer']);
}
- public function test_a_box_office_order_without_an_email_is_refunded_without_notifying_the_buyer(): void
+ public function test_the_buyer_is_not_flagged_when_the_organizer_did_not_ask_for_the_email(): void
{
- $order = $this->givenOrderIsFound(email: null);
- $this->givenEventIsFound();
- $this->givenRefundSucceeds();
+ $this->givenOrderIsFound(email: 'buyer@example.com');
+ $this->givenFailedRefunds(0);
+ $this->givenStripeAcceptsTheRefund();
+
+ $this->handler->handle($this->givenDTO(notifyBuyer: false));
+
+ $this->assertFalse($this->refundRequest['notifyBuyer']);
+ }
- $this->mailer->shouldNotReceive('to');
- $this->mailer->shouldNotReceive('send');
+ public function test_a_box_office_order_without_an_email_is_refunded_without_flagging_the_buyer(): void
+ {
+ $order = $this->givenOrderIsFound(email: null);
+ $this->givenFailedRefunds(0);
+ $this->givenStripeAcceptsTheRefund();
$this->assertSame($order, $this->handler->handle($this->givenDTO(notifyBuyer: true)));
+
+ $this->assertFalse($this->refundRequest['notifyBuyer']);
+ }
+
+ public function test_the_refund_carries_the_amount_already_refunded_and_the_failed_attempts_so_far(): void
+ {
+ $this->givenOrderIsFound(
+ email: null,
+ totalGross: 40.0,
+ totalRefunded: 20.0,
+ refundStatus: OrderRefundStatus::PARTIALLY_REFUNDED->name,
+ );
+ $this->givenFailedRefunds(1);
+ $this->givenStripeAcceptsTheRefund();
+
+ $this->handler->handle($this->givenDTO(amount: 20.0));
+
+ $this->assertSame(2000, $this->refundRequest['amount']->toMinorUnit());
+ $this->assertSame(2000, $this->refundRequest['alreadyRefunded']->toMinorUnit());
+ $this->assertSame('USD', $this->refundRequest['alreadyRefunded']->getMoney()->getCurrency()->getCurrencyCode());
+ $this->assertSame(1, $this->refundRequest['failedRefundCount']);
+ }
+
+ public function test_a_second_refund_is_refused_while_one_is_pending(): void
+ {
+ $order = (new OrderDomainObject)
+ ->setId(self::ORDER_ID)
+ ->setEventId(self::EVENT_ID)
+ ->setCurrency('USD')
+ ->setRefundStatus(OrderRefundStatus::REFUND_PENDING->name)
+ ->setStripePayment(new StripePaymentDomainObject);
+
+ $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->orderRepository->shouldReceive('findFirstWhere')->andReturn($order);
+ $this->orderRepository->shouldNotReceive('updateFromArray');
+ $this->refundService->shouldNotReceive('refundPayment');
+
+ $this->expectException(RefundNotPossibleException::class);
+
+ $this->handler->handle($this->givenDTO());
}
public function test_the_order_is_marked_refund_pending_before_the_stripe_refund_is_created(): void
{
$calls = [];
- $order = $this->givenOrderIsFound(email: null, onUpdate: function (int $id, array $attributes) use (&$calls) {
+ $this->givenOrderIsFound(email: null, onUpdate: function (int $id, array $attributes) use (&$calls) {
$calls[] = 'mark:'.$attributes[OrderDomainObjectAbstract::REFUND_STATUS];
});
- $this->givenEventIsFound();
+ $this->givenFailedRefunds(0);
$this->refundService->shouldReceive('refundPayment')->once()->andReturnUsing(function () use (&$calls) {
$calls[] = 'stripe-refund';
- return Mockery::mock(Refund::class);
+ return Refund::constructFrom(['id' => 're_1', 'status' => 'pending']);
});
$this->handler->handle($this->givenDTO());
@@ -114,27 +153,33 @@ public function test_the_order_is_marked_refund_pending_before_the_stripe_refund
$this->assertSame(['mark:'.OrderRefundStatus::REFUND_PENDING->name, 'stripe-refund'], $calls);
}
- private function givenDTO(bool $notifyBuyer = false): RefundOrderDTO
+ private function givenDTO(bool $notifyBuyer = false, float $amount = 50.0): RefundOrderDTO
{
return new RefundOrderDTO(
event_id: self::EVENT_ID,
order_id: self::ORDER_ID,
- amount: 50.0,
+ amount: $amount,
notify_buyer: $notifyBuyer,
cancel_order: false,
);
}
- private function givenOrderIsFound(?string $email, ?callable $onUpdate = null): OrderDomainObject
- {
+ private function givenOrderIsFound(
+ ?string $email,
+ ?callable $onUpdate = null,
+ float $totalGross = 100.0,
+ float $totalRefunded = 0.0,
+ ?string $refundStatus = null,
+ ): OrderDomainObject {
$order = (new OrderDomainObject)
->setId(self::ORDER_ID)
->setEventId(self::EVENT_ID)
->setCurrency('USD')
->setEmail($email)
->setLocale('en')
- ->setTotalGross(100.0)
- ->setTotalRefunded(0.0)
+ ->setTotalGross($totalGross)
+ ->setTotalRefunded($totalRefunded)
+ ->setRefundStatus($refundStatus)
->setStripePayment(new StripePaymentDomainObject);
$this->orderRepository->shouldReceive('loadRelation')->once()->andReturnSelf();
@@ -156,21 +201,33 @@ private function givenOrderIsFound(?string $email, ?callable $onUpdate = null):
return $order;
}
- private function givenEventIsFound(): void
+ private function givenFailedRefunds(int $count): void
{
- $event = Mockery::mock(EventDomainObject::class);
- $event->shouldReceive('getOrganizer')->andReturn(Mockery::mock(OrganizerDomainObject::class));
- $event->shouldReceive('getEventSettings')->andReturn(
- Mockery::mock(EventSettingDomainObject::class)->shouldIgnoreMissing()
- );
-
- $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
- $this->eventRepository->shouldReceive('findById')->once()->with(self::EVENT_ID)->andReturn($event);
+ $this->orderRefundRepository->shouldReceive('countWhere')
+ ->once()
+ ->with([
+ 'order_id' => self::ORDER_ID,
+ ['status', 'in', [Refund::STATUS_FAILED, Refund::STATUS_CANCELED]],
+ ])
+ ->andReturn($count);
}
- private function givenRefundSucceeds(): void
+ private function givenStripeAcceptsTheRefund(?Refund $refund = null): void
{
- $this->refundService->shouldReceive('refundPayment')->once();
+ $this->refundService->shouldReceive('refundPayment')
+ ->once()
+ ->andReturnUsing(function (
+ MoneyValue $amount,
+ MoneyValue $alreadyRefunded,
+ int $failedRefundCount,
+ StripePaymentDomainObject $payment,
+ StripeClient $stripeClient,
+ bool $notifyBuyer,
+ ) use ($refund) {
+ $this->refundRequest = compact('amount', 'alreadyRefunded', 'failedRefundCount', 'notifyBuyer');
+
+ return $refund ?? Refund::constructFrom(['id' => 're_1', 'status' => 'pending']);
+ });
}
protected function tearDown(): void
diff --git a/backend/tests/Unit/Services/Application/Handlers/OrganizerSettings/PartialUpdateOrganizerSettingsEventDefaultsTest.php b/backend/tests/Unit/Services/Application/Handlers/OrganizerSettings/PartialUpdateOrganizerSettingsEventDefaultsTest.php
new file mode 100644
index 0000000000..a0a330561f
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/OrganizerSettings/PartialUpdateOrganizerSettingsEventDefaultsTest.php
@@ -0,0 +1,112 @@
+settingsRepository = Mockery::mock(OrganizerSettingsRepositoryInterface::class);
+ $this->organizerRepository = Mockery::mock(OrganizerRepositoryInterface::class);
+
+ $this->handler = new PartialUpdateOrganizerSettingsHandler(
+ organizerSettingsRepository: $this->settingsRepository,
+ organizerRepository: $this->organizerRepository,
+ eventSpamCheckDispatchService: Mockery::mock(EventSpamCheckDispatchService::class)->shouldIgnoreMissing(),
+ homepageSectionsSanitizer: Mockery::mock(HomepageSectionsSanitizer::class),
+ );
+ }
+
+ public function test_turning_attendee_self_service_off_is_saved(): void
+ {
+ $this->handle(new OrganizerSettingDomainObject, ['default_allow_attendee_self_edit' => false]);
+
+ $this->assertArrayHasKey('default_allow_attendee_self_edit', $this->savedAttributes);
+ $this->assertFalse($this->savedAttributes['default_allow_attendee_self_edit']);
+ }
+
+ public function test_turning_attendee_self_service_back_on_is_saved(): void
+ {
+ $existing = (new OrganizerSettingDomainObject)->setDefaultAllowAttendeeSelfEdit(false);
+
+ $this->handle($existing, ['default_allow_attendee_self_edit' => true]);
+
+ $this->assertTrue($this->savedAttributes['default_allow_attendee_self_edit']);
+ }
+
+ public static function untouchedInputs(): array
+ {
+ return [
+ 'omitted' => [[]],
+ 'explicit null' => [[
+ 'default_allow_attendee_self_edit' => null,
+ 'default_show_marketing_opt_in' => null,
+ 'default_pass_platform_fee_to_buyer' => null,
+ ]],
+ ];
+ }
+
+ #[DataProvider('untouchedInputs')]
+ public function test_event_default_switches_keep_their_saved_value_when_not_set(array $input): void
+ {
+ $existing = (new OrganizerSettingDomainObject)
+ ->setDefaultAllowAttendeeSelfEdit(false)
+ ->setDefaultShowMarketingOptIn(true)
+ ->setDefaultPassPlatformFeeToBuyer(true);
+
+ $this->handle($existing, $input);
+
+ $this->assertFalse($this->savedAttributes['default_allow_attendee_self_edit']);
+ $this->assertTrue($this->savedAttributes['default_show_marketing_opt_in']);
+ $this->assertTrue($this->savedAttributes['default_pass_platform_fee_to_buyer']);
+ }
+
+ private function handle(OrganizerSettingDomainObject $existing, array $input): void
+ {
+ $organizer = (new OrganizerDomainObject)->setId(1);
+ $existing->setId(10)->setOrganizerId(1);
+
+ $this->organizerRepository->shouldReceive('findFirstWhere')->once()->andReturn($organizer);
+ $this->settingsRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+ $this->settingsRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(Mockery::on(function (array $attributes) {
+ $this->savedAttributes = $attributes;
+
+ return true;
+ }), ['organizer_id' => 1, 'id' => 10]);
+ $this->settingsRepository->shouldReceive('findFirst')->with(10)->once()->andReturn($existing);
+
+ $this->handler->handle(PartialUpdateOrganizerSettingsDTO::from([
+ 'organizer_id' => 1,
+ 'account_id' => 100,
+ ...$input,
+ ]));
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/User/UpdateMeHandlerPasswordChangeTest.php b/backend/tests/Unit/Services/Application/Handlers/User/UpdateMeHandlerPasswordChangeTest.php
new file mode 100644
index 0000000000..01bd6f5f84
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/User/UpdateMeHandlerPasswordChangeTest.php
@@ -0,0 +1,89 @@
+userRepository = Mockery::mock(UserRepositoryInterface::class);
+ $this->trustedDeviceService = Mockery::mock(TrustedDeviceService::class);
+
+ $hasher = Mockery::mock(Hasher::class);
+ $hasher->shouldReceive('check')->andReturnUsing(fn (string $value, ?string $hash) => $value === 'current-secret' && $hash === 'hashed');
+ $hasher->shouldReceive('make')->with('new-secret')->andReturn('new-hash');
+
+ $this->handler = new UpdateMeHandler(
+ $this->userRepository,
+ $hasher,
+ Mail::getFacadeRoot(),
+ Mockery::mock(EncryptedPayloadService::class),
+ $this->trustedDeviceService,
+ Mockery::mock(AccountUserRepositoryInterface::class),
+ Mockery::mock(IdentityConfirmationService::class),
+ Mockery::mock(SsoEnforcementService::class),
+ );
+ }
+
+ public function test_a_password_change_sent_with_profile_details_saves_both(): void
+ {
+ $this->userRepository->shouldReceive('findByIdAndAccountId')->with(1, 9)->andReturn(
+ (new UserDomainObject)
+ ->setId(1)
+ ->setEmail('dave@example.com')
+ ->setLocale('en')
+ ->setPassword('hashed')
+ );
+ $this->trustedDeviceService->shouldReceive('revokeAll')->once()->with(1);
+ $this->userRepository->shouldReceive('updateWhere')->once()->with(
+ [
+ 'password' => 'new-hash',
+ 'first_name' => 'Dave',
+ 'last_name' => 'Earley',
+ 'timezone' => 'UTC',
+ 'locale' => 'en',
+ ],
+ ['id' => 1],
+ );
+
+ $this->handler->handle(new UpdateMeDTO(
+ id: 1,
+ account_id: 9,
+ first_name: 'Dave',
+ last_name: 'Earley',
+ email: 'dave@example.com',
+ timezone: 'UTC',
+ password: 'new-secret',
+ current_password: 'current-secret',
+ locale: 'en',
+ ));
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php
index 60e7836e9c..edea2e7e16 100644
--- a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php
+++ b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php
@@ -233,6 +233,34 @@ public function test_occurrence_tokens_empty_when_no_occurrence(): void
$this->assertEquals('', $context['occurrence']['label']);
}
+ public function test_an_occurrence_without_an_end_leaves_the_end_tokens_empty(): void
+ {
+ $occurrence = (new EventOccurrenceDomainObject)
+ ->setId(7)
+ ->setStartDate('2026-11-02 19:00:00')
+ ->setEndDate(null);
+ $series = Mockery::mock(EventDomainObject::class, [
+ 'getTitle' => 'Weekly Quiz',
+ 'getDescription' => null,
+ 'getStartDate' => '2026-11-02 19:00:00',
+ 'getEndDate' => '2026-11-16 19:00:00',
+ 'getTimezone' => 'UTC',
+ 'getCurrency' => 'USD',
+ 'getId' => 1,
+ 'getEventLocation' => null,
+ ]);
+
+ $context = $this->contextBuilder->buildOrderConfirmationContext(
+ $this->createMockOrder(), $series, $this->createMockOrganizer(), $this->createMockEventSettings(), $occurrence
+ );
+
+ $this->assertEquals('November 2, 2026', $context['event']['date']);
+ $this->assertSame('', $context['event']['end_date']);
+ $this->assertSame('', $context['event']['end_time']);
+ $this->assertSame('', $context['occurrence']['end_date']);
+ $this->assertSame('', $context['occurrence']['end_time']);
+ }
+
public function test_uses_the_orders_only_occurrence_when_none_is_passed(): void
{
$occurrence = (new EventOccurrenceDomainObject)
diff --git a/backend/tests/Unit/Services/Domain/Event/EventSpamCheckContentServiceTest.php b/backend/tests/Unit/Services/Domain/Event/EventSpamCheckContentServiceTest.php
index ea30c4db76..1832862695 100644
--- a/backend/tests/Unit/Services/Domain/Event/EventSpamCheckContentServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Event/EventSpamCheckContentServiceTest.php
@@ -7,6 +7,7 @@
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventLineupEntryDomainObject;
use HiEvents\DomainObjects\EventScheduleItemDomainObject;
+use HiEvents\DomainObjects\EventScheduleItemPerformerDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\EventSpaceDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
@@ -16,7 +17,11 @@
use HiEvents\DomainObjects\ProfileDomainObject;
use HiEvents\DomainObjects\QuestionDomainObject;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
+use HiEvents\Services\Domain\Event\DTO\EventSpamCheckContentDTO;
use HiEvents\Services\Domain\Event\EventSpamCheckContentService;
+use HiEvents\Services\Domain\Event\EventSpamCheckService;
+use HiEvents\Services\Domain\Lineup\LineupHighlightSelector;
+use Illuminate\Config\Repository;
use Illuminate\Support\Collection;
use Mockery;
use Tests\TestCase;
@@ -29,7 +34,7 @@ protected function setUp(): void
{
parent::setUp();
- $this->service = new EventSpamCheckContentService(Mockery::mock(EventRepositoryInterface::class));
+ $this->service = new EventSpamCheckContentService(Mockery::mock(EventRepositoryInterface::class), new LineupHighlightSelector);
}
protected function tearDown(): void
@@ -65,6 +70,84 @@ public function test_collects_content_from_every_public_surface(): void
], $content->supplementaryContent);
}
+ public function test_events_without_a_lineup_keep_the_content_hash_they_had_before_lineups(): void
+ {
+ $event = $this->makeEvent()
+ ->setOrganizer((new OrganizerDomainObject)
+ ->setName('Patches Maker UK')
+ ->setDescription('Organizer bio
')
+ ->setWebsite('https://organizer.example')
+ ->setOrganizerSettings((new OrganizerSettingDomainObject)
+ ->setWebsiteUrl('https://settings.example')
+ ->setSeoTitle('Organizer SEO title')
+ ->setSeoDescription('Organizer SEO description')
+ ->setSeoKeywords('organizer, keywords')
+ ->setHomepageSections([['id' => 'a', 'type' => 'TEXT', 'heading' => 'Our story', 'body' => 'Since 2010
']])))
+ ->setEventSettings((new EventSettingDomainObject)
+ ->setProductPageMessage('Product page message')
+ ->setPreCheckoutMessage('Pre checkout')
+ ->setPostCheckoutMessage('Post checkout')
+ ->setOfflinePaymentInstructions('Bank transfer')
+ ->setEmailFooterMessage('Footer')
+ ->setOnlineEventConnectionDetails('Join at the link')
+ ->setMapsUrl('https://maps.example')
+ ->setSeoTitle('SEO title')
+ ->setSeoDescription('SEO description')
+ ->setSeoKeywords('seo, keywords')
+ ->setHomepageSections([['id' => 'f', 'type' => 'FAQ', 'faqs' => [['question' => 'Parking?', 'answer' => 'Yes
']]]]))
+ ->setProducts(new Collection([
+ (new ProductDomainObject)->setTitle('General Admission')->setDescription('Ticket blurb
'),
+ (new ProductDomainObject)->setTitle('VIP')->setDescription('VIP blurb
'),
+ ]))
+ ->setProductCategories(new Collection([
+ (new ProductCategoryDomainObject)->setName('Tickets')->setDescription('Category blurb
'),
+ (new ProductCategoryDomainObject)->setName('Extras'),
+ ]));
+
+ $contentBeforeLineups = new EventSpamCheckContentDTO(
+ title: 'Event Title',
+ description: 'Event description
',
+ supplementaryContent: [
+ 'organizer name' => 'Patches Maker UK',
+ 'organizer description' => 'Organizer bio
',
+ 'organizer website' => 'https://organizer.example',
+ 'organizer settings website' => 'https://settings.example',
+ 'organizer seo title' => 'Organizer SEO title',
+ 'organizer seo description' => 'Organizer SEO description',
+ 'organizer seo keywords' => 'organizer, keywords',
+ 'organizer homepage section 1 heading' => 'Our story',
+ 'organizer homepage section 1 body' => 'Since 2010
',
+ 'product page message' => 'Product page message',
+ 'pre-checkout message' => 'Pre checkout',
+ 'post-checkout message' => 'Post checkout',
+ 'offline payment instructions' => 'Bank transfer',
+ 'email footer message' => 'Footer',
+ 'online event connection details' => 'Join at the link',
+ 'maps url' => 'https://maps.example',
+ 'seo title' => 'SEO title',
+ 'seo description' => 'SEO description',
+ 'seo keywords' => 'seo, keywords',
+ 'homepage section 1 question 1' => 'Parking?',
+ 'homepage section 1 answer 1' => 'Yes
',
+ 'product 1 title' => 'General Admission',
+ 'product 1 description' => 'Ticket blurb
',
+ 'product 2 title' => 'VIP',
+ 'product 2 description' => 'VIP blurb
',
+ 'category 1 name' => 'Tickets',
+ 'category 1 description' => 'Category blurb
',
+ 'category 2 name' => 'Extras',
+ 'question 1 title' => 'Dietary requirements',
+ 'question 1 description' => 'Question blurb
',
+ ],
+ );
+ $hasher = new EventSpamCheckService(new Repository([]));
+
+ $this->assertSame(
+ $hasher->hashContent($contentBeforeLineups),
+ $hasher->hashContent($this->service->buildForEvent($event)),
+ );
+ }
+
public function test_collects_lineup_and_schedule_content(): void
{
$event = (new EventDomainObject)
@@ -73,6 +156,7 @@ public function test_collects_lineup_and_schedule_content(): void
->setTitle('Festival')
->setEventLineupEntries(new Collection([
(new EventLineupEntryDomainObject)
+ ->setId(1)
->setRoleLabel('Headliner')
->setProfile((new ProfileDomainObject)
->setName('Nova Lights')
@@ -80,23 +164,110 @@ public function test_collects_lineup_and_schedule_content(): void
->setBio('Artist bio
')
->setLinks([['type' => 'WEBSITE', 'url' => 'https://nova.example'], ['type' => 'SPOTIFY', 'url' => 'https://open.spotify.com/a']])),
]))
- ->setEventSpaces(new Collection([(new EventSpaceDomainObject)->setName('Main Stage')]))
+ ->setEventSpaces(new Collection([(new EventSpaceDomainObject)->setId(1)->setName('Main Stage')]))
->setEventScheduleItems(new Collection([
- (new EventScheduleItemDomainObject)->setTitle('Closing set')->setDescription('Set notes
'),
+ (new EventScheduleItemDomainObject)->setId(1)->setTitle('Closing set')->setDescription('Set notes
'),
]));
$this->assertSame([
+ 'ticket email lineup 1 name' => 'Nova Lights',
+ 'ticket email schedule item 1 title' => 'Closing set',
+ 'space 1 name' => 'Main Stage',
'lineup 1 role' => 'Headliner',
- 'lineup 1 name' => 'Nova Lights',
'lineup 1 tagline' => 'Berlin techno',
'lineup 1 bio' => 'Artist bio
',
'lineup 1 links' => 'https://nova.example https://open.spotify.com/a',
- 'space 1 name' => 'Main Stage',
- 'schedule item 1 title' => 'Closing set',
'schedule item 1 description' => 'Set notes
',
], $this->service->buildForEvent($event)->supplementaryContent);
}
+ public function test_orders_lineup_like_ticket_emails_with_hidden_entries_last_and_slots_by_start_time(): void
+ {
+ $entry = fn (int $id, string $tier, int $sortOrder, bool $hidden, string $name) => (new EventLineupEntryDomainObject)
+ ->setId($id)
+ ->setBillingTier($tier)
+ ->setSortOrder($sortOrder)
+ ->setIsHidden($hidden)
+ ->setProfile((new ProfileDomainObject)->setName($name)->setBio("$name bio
"));
+
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setAccountId(9)
+ ->setTitle('Festival')
+ ->setEventLineupEntries(new Collection([
+ $entry(1, 'STANDARD', 0, false, 'Opener'),
+ $entry(2, 'HEADLINER', 5, true, 'Secret Headliner'),
+ $entry(3, 'FEATURED', 1, false, 'Featured Act'),
+ $entry(4, 'HEADLINER', 3, false, 'Second Headliner'),
+ $entry(5, 'HEADLINER', 2, false, 'First Headliner'),
+ ]))
+ ->setEventScheduleItems(new Collection([
+ (new EventScheduleItemDomainObject)->setId(1)->setStartsAt('2030-06-14 22:00:00')->setTitle('Late set'),
+ (new EventScheduleItemDomainObject)->setId(2)->setStartsAt('2030-06-14 19:00:00')->setTitle('Doors'),
+ ]));
+
+ $this->assertSame([
+ 'ticket email lineup 1 name' => 'First Headliner',
+ 'ticket email lineup 2 name' => 'Second Headliner',
+ 'ticket email schedule item 1 title' => 'Doors',
+ 'ticket email schedule item 2 title' => 'Late set',
+ 'lineup 3 name' => 'Featured Act',
+ 'lineup 4 name' => 'Opener',
+ 'lineup 1 bio' => 'First Headliner bio
',
+ 'lineup 2 bio' => 'Second Headliner bio
',
+ 'lineup 3 bio' => 'Featured Act bio
',
+ 'lineup 4 bio' => 'Opener bio
',
+ 'lineup 5 name' => 'Secret Headliner',
+ 'lineup 5 bio' => 'Secret Headliner bio
',
+ ], $this->service->buildForEvent($event)->supplementaryContent);
+ }
+
+ public function test_labels_the_lineup_ticket_emails_show_and_lists_it_first(): void
+ {
+ $entry = fn (int $id, string $tier, int $sortOrder, string $name) => (new EventLineupEntryDomainObject)
+ ->setId($id)
+ ->setBillingTier($tier)
+ ->setSortOrder($sortOrder)
+ ->setProfile((new ProfileDomainObject)->setName($name));
+ $performers = fn (int ...$entryIds) => new Collection(array_map(
+ fn (int $entryId) => (new EventScheduleItemPerformerDomainObject)->setEventLineupEntryId($entryId),
+ $entryIds,
+ ));
+
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setAccountId(9)
+ ->setTitle('Festival')
+ ->setEventSettings((new EventSettingDomainObject)->setEmailFooterMessage('Footer'))
+ ->setProducts(new Collection([(new ProductDomainObject)->setTitle('Day ticket')]))
+ ->setEventSpaces(new Collection([
+ (new EventSpaceDomainObject)->setId(10)->setName('Main Stage'),
+ (new EventSpaceDomainObject)->setId(11)->setName('Tent'),
+ ]))
+ ->setEventLineupEntries(new Collection([
+ $entry(1, 'HEADLINER', 0, 'Nova'),
+ $entry(2, 'HEADLINER', 1, 'Orbit'),
+ $entry(3, 'STANDARD', 2, 'Opener'),
+ ]))
+ ->setEventScheduleItems(new Collection([
+ (new EventScheduleItemDomainObject)->setId(1)->setStartsAt('2030-06-14 18:00:00')->setEventSpaceId(11)->setTitle('Doors'),
+ (new EventScheduleItemDomainObject)->setId(2)->setStartsAt('2030-06-14 21:00:00')->setEventSpaceId(10)->setPerformers($performers(1)),
+ (new EventScheduleItemDomainObject)->setId(3)->setStartsAt('2030-06-14 22:00:00')->setTitle('Encore')->setPerformers($performers(2, 3)),
+ ]));
+
+ $this->assertSame([
+ 'ticket email lineup 2 name' => 'Orbit',
+ 'ticket email lineup 1 name' => 'Nova',
+ 'ticket email space 1 name' => 'Main Stage',
+ 'ticket email schedule item 3 title' => 'Encore',
+ 'lineup 3 name' => 'Opener',
+ 'schedule item 1 title' => 'Doors',
+ 'space 2 name' => 'Tent',
+ 'email footer message' => 'Footer',
+ 'product 1 title' => 'Day ticket',
+ ], $this->service->buildForEvent($event)->supplementaryContent);
+ }
+
public function test_collects_homepage_section_content(): void
{
$event = (new EventDomainObject)
diff --git a/backend/tests/Unit/Services/Domain/Event/EventSpamCheckPromptBudgetTest.php b/backend/tests/Unit/Services/Domain/Event/EventSpamCheckPromptBudgetTest.php
new file mode 100644
index 0000000000..ae3d10889d
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Event/EventSpamCheckPromptBudgetTest.php
@@ -0,0 +1,246 @@
+contentService = new EventSpamCheckContentService(Mockery::mock(EventRepositoryInterface::class), new LineupHighlightSelector);
+ $this->checkService = new EventSpamCheckService(new Repository([
+ 'app' => ['event_spam_check_confidence_threshold' => 0.7],
+ ]));
+
+ EventSpamDetectionAgent::fake([['is_spam' => false, 'confidence' => 0.9, 'reasons' => []]]);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_headliner_names_and_slot_titles_reach_the_prompt_behind_padded_bios_and_slot_notes(): void
+ {
+ $entries = [];
+
+ foreach (range(1, 9) as $id) {
+ $entries[] = $this->entry($id, 'STANDARD', sortOrder: $id - 1, name: "Opener $id", bio: $this->padding());
+ }
+
+ $entries[] = $this->entry(10, 'HEADLINER', sortOrder: 9, name: self::PHISHING);
+
+ $items = [];
+
+ foreach (range(1, 9) as $id) {
+ $items[] = (new EventScheduleItemDomainObject)
+ ->setId($id)
+ ->setStartsAt(sprintf('2030-06-14 %02d:00:00', 10 + $id))
+ ->setDescription($this->padding());
+ }
+
+ $items[] = (new EventScheduleItemDomainObject)
+ ->setId(10)
+ ->setStartsAt('2030-06-14 23:00:00')
+ ->setTitle('Refund desk: call +1 805 555 0199');
+
+ $this->check($this->event()
+ ->setEventLineupEntries(new Collection($entries))
+ ->setEventScheduleItems(new Collection($items)));
+
+ EventSpamDetectionAgent::assertPrompted(fn ($prompt) => str_contains($prompt->prompt, ': '.self::PHISHING)
+ && str_contains($prompt->prompt, ': Refund desk: call +1 805 555 0199'));
+ }
+
+ public function test_email_slot_title_and_stage_reach_the_prompt_behind_a_long_lineup(): void
+ {
+ $entries = array_map(
+ fn (int $id) => $this->entry($id, 'STANDARD', sortOrder: $id, name: str_pad("Act $id ", 250, 'x')),
+ range(1, 16),
+ );
+
+ $this->check($this->event()
+ ->setEventLineupEntries(new Collection($entries))
+ ->setEventSpaces(new Collection([
+ (new EventSpaceDomainObject)->setId(1)->setName('Side room'),
+ (new EventSpaceDomainObject)->setId(7)->setName('Text 805 555 0123 for refunds'),
+ ]))
+ ->setEventScheduleItems(new Collection([
+ (new EventScheduleItemDomainObject)
+ ->setId(1)
+ ->setEventSpaceId(7)
+ ->setStartsAt('2030-06-14 20:00:00')
+ ->setTitle('Refund desk: call +1 805 555 0199'),
+ ])));
+
+ EventSpamDetectionAgent::assertPrompted(fn ($prompt) => str_contains($prompt->prompt, ': Refund desk: call +1 805 555 0199')
+ && str_contains($prompt->prompt, ': Text 805 555 0123 for refunds'));
+ }
+
+ public function test_headliner_slot_title_reaches_the_prompt_behind_earlier_padded_slots(): void
+ {
+ $items = array_map(
+ fn (int $id) => (new EventScheduleItemDomainObject)
+ ->setId($id)
+ ->setStartsAt(sprintf('2030-06-14 %02d:00:00', $id))
+ ->setTitle(str_pad("Workshop $id ", 250, 'x')),
+ range(1, 20),
+ );
+
+ $items[] = (new EventScheduleItemDomainObject)
+ ->setId(21)
+ ->setStartsAt('2030-06-14 23:00:00')
+ ->setTitle(self::PHISHING)
+ ->setPerformers(new Collection([(new EventScheduleItemPerformerDomainObject)->setEventLineupEntryId(1)]));
+
+ $this->check($this->event()
+ ->setEventLineupEntries(new Collection([$this->entry(1, 'HEADLINER', sortOrder: 0, name: 'Nova Lights')]))
+ ->setEventScheduleItems(new Collection($items)));
+
+ EventSpamDetectionAgent::assertPrompted(fn ($prompt) => str_contains($prompt->prompt, ': '.self::PHISHING));
+ }
+
+ public function test_product_titles_reach_the_prompt_behind_padded_descriptions_and_homepage_sections(): void
+ {
+ $products = [];
+
+ foreach (range(1, 9) as $index) {
+ $products[] = (new ProductDomainObject)->setTitle("Ticket $index")->setDescription($this->padding());
+ }
+
+ $products[] = (new ProductDomainObject)->setTitle(self::PHISHING);
+
+ $this->check($this->event()
+ ->setOrganizer($this->organizerWithPaddedHomepage())
+ ->setProducts(new Collection($products)));
+
+ EventSpamDetectionAgent::assertPrompted(fn ($prompt) => str_contains($prompt->prompt, 'product 10 title: '.self::PHISHING));
+ }
+
+ public function test_email_messages_reach_the_prompt_behind_padded_organizer_homepage_sections(): void
+ {
+ $this->check($this->event()
+ ->setOrganizer($this->organizerWithPaddedHomepage())
+ ->setEventSettings((new EventSettingDomainObject)
+ ->setEmailFooterMessage(''.self::PHISHING.'
')
+ ->setOfflinePaymentInstructions('Wire the fee to account 12345
')));
+
+ EventSpamDetectionAgent::assertPrompted(fn ($prompt) => str_contains($prompt->prompt, 'email footer message: '.self::PHISHING)
+ && str_contains($prompt->prompt, 'offline payment instructions: Wire the fee to account 12345'));
+ }
+
+ public function test_prompt_leads_with_what_ticket_emails_show_and_keeps_the_rest_behind_email_messages(): void
+ {
+ $entry = fn (int $id, string $tier, int $sortOrder, string $name) => $this->entry($id, $tier, $sortOrder, $name)->setRoleLabel("$name role");
+ $performers = fn (int ...$entryIds) => new Collection(array_map(
+ fn (int $entryId) => (new EventScheduleItemPerformerDomainObject)->setEventLineupEntryId($entryId),
+ $entryIds,
+ ));
+
+ $this->check($this->event()
+ ->setOrganizer((new OrganizerDomainObject)->setName('Promoter')->setDescription('About us
'))
+ ->setEventSettings((new EventSettingDomainObject)->setPreCheckoutMessage('Pre checkout')->setEmailFooterMessage('Footer'))
+ ->setProducts(new Collection([(new ProductDomainObject)->setTitle('Day ticket')->setDescription('Entry
')]))
+ ->setEventSpaces(new Collection([
+ (new EventSpaceDomainObject)->setId(10)->setName('Main Stage'),
+ (new EventSpaceDomainObject)->setId(11)->setName('Tent'),
+ ]))
+ ->setEventLineupEntries(new Collection([
+ $entry(1, 'HEADLINER', 0, 'Nova'),
+ $entry(2, 'HEADLINER', 1, 'Orbit'),
+ $entry(3, 'STANDARD', 2, 'Opener'),
+ ]))
+ ->setEventScheduleItems(new Collection([
+ (new EventScheduleItemDomainObject)->setId(1)->setStartsAt('2030-06-14 18:00:00')->setEventSpaceId(11)->setTitle('Doors'),
+ (new EventScheduleItemDomainObject)->setId(2)->setStartsAt('2030-06-14 21:00:00')->setEventSpaceId(10)->setPerformers($performers(1)),
+ (new EventScheduleItemDomainObject)->setId(3)->setStartsAt('2030-06-14 22:00:00')->setTitle('Encore')->setPerformers($performers(2, 3)),
+ ])));
+
+ EventSpamDetectionAgent::assertPrompted(fn ($prompt) => str_contains($prompt->prompt, implode("\n", [
+ 'organizer name: Promoter',
+ 'ticket email lineup 2 name: Orbit',
+ 'ticket email lineup 1 name: Nova',
+ 'ticket email space 1 name: Main Stage',
+ 'ticket email schedule item 3 title: Encore',
+ 'product 1 title: Day ticket',
+ 'email footer message: Footer',
+ 'lineup 3 name: Opener',
+ 'schedule item 1 title: Doors',
+ 'space 2 name: Tent',
+ 'organizer description: About us',
+ 'pre-checkout message: Pre checkout',
+ 'product 1 description: Entry',
+ 'lineup 1 role: Nova role',
+ ])));
+ }
+
+ private function check(EventDomainObject $event): void
+ {
+ $this->checkService->checkContent($this->contentService->buildForEvent($event));
+ }
+
+ private function event(): EventDomainObject
+ {
+ return (new EventDomainObject)
+ ->setId(1)
+ ->setAccountId(9)
+ ->setTitle('Summer Festival')
+ ->setDescription('Three days of music
');
+ }
+
+ private function organizerWithPaddedHomepage(): OrganizerDomainObject
+ {
+ return (new OrganizerDomainObject)
+ ->setName('Promoter')
+ ->setDescription($this->padding())
+ ->setOrganizerSettings((new OrganizerSettingDomainObject)->setHomepageSections(array_map(
+ fn (int $index) => ['id' => "s$index", 'type' => 'TEXT', 'heading' => "Section $index", 'body' => $this->padding()],
+ range(1, 9),
+ )));
+ }
+
+ private function entry(int $id, string $tier, int $sortOrder, string $name, ?string $bio = null): EventLineupEntryDomainObject
+ {
+ return (new EventLineupEntryDomainObject)
+ ->setId($id)
+ ->setBillingTier($tier)
+ ->setSortOrder($sortOrder)
+ ->setIsHidden(false)
+ ->setProfile((new ProfileDomainObject)->setName($name)->setBio($bio));
+ }
+
+ private function padding(): string
+ {
+ return ''.str_repeat('Long biography text that fills the budget. ', 15).'
';
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Lineup/EmailLineupHighlightsServiceTest.php b/backend/tests/Unit/Services/Domain/Lineup/EmailLineupHighlightsServiceTest.php
index 177ef7b448..16d57817a0 100644
--- a/backend/tests/Unit/Services/Domain/Lineup/EmailLineupHighlightsServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Lineup/EmailLineupHighlightsServiceTest.php
@@ -15,6 +15,7 @@
use HiEvents\Repository\Interfaces\EventScheduleItemRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSpaceRepositoryInterface;
use HiEvents\Services\Domain\Lineup\EmailLineupHighlightsService;
+use HiEvents\Services\Domain\Lineup\LineupHighlightSelector;
use Illuminate\Support\Collection;
use Mockery as m;
use Tests\TestCase;
@@ -34,7 +35,7 @@ private function service(array $entries, array $items): EmailLineupHighlightsSer
$spaceRepository = m::mock(EventSpaceRepositoryInterface::class);
$spaceRepository->shouldReceive('findWhere')->andReturn(new Collection([(new EventSpaceDomainObject)->setId(9)->setName('Main Stage')]));
- return new EmailLineupHighlightsService($entryRepository, $itemRepository, $spaceRepository);
+ return new EmailLineupHighlightsService($entryRepository, $itemRepository, $spaceRepository, new LineupHighlightSelector);
}
private function entry(int $id, string $name, string $tier): EventLineupEntryDomainObject
diff --git a/backend/tests/Unit/Services/Domain/Lineup/LineupHighlightSelectorTest.php b/backend/tests/Unit/Services/Domain/Lineup/LineupHighlightSelectorTest.php
new file mode 100644
index 0000000000..5a7a7fd40d
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Lineup/LineupHighlightSelectorTest.php
@@ -0,0 +1,90 @@
+ $this->entry(1, 'FEATURED')];
+
+ foreach (range(2, 9) as $id) {
+ $entries[$id + 2] = $this->entry($id, 'HEADLINER');
+ }
+
+ $selection = (new LineupHighlightSelector)->select(new Collection($entries), new Collection);
+
+ $this->assertSame([4, 5, 6, 7, 8, 9], $selection->entries->keys()->all());
+ $this->assertSame([2, 3, 4, 5, 6, 7], $selection->entries->map->getId()->values()->all());
+ $this->assertCount(0, $selection->slots);
+ }
+
+ public function test_falls_back_to_featured_then_everyone(): void
+ {
+ $selector = new LineupHighlightSelector;
+
+ $featured = $selector->select(new Collection([$this->entry(1, 'STANDARD'), $this->entry(2, 'FEATURED')]), new Collection);
+ $everyone = $selector->select(new Collection([$this->entry(1, 'STANDARD'), $this->entry(2, 'STANDARD')]), new Collection);
+
+ $this->assertSame([2], $featured->entries->map->getId()->values()->all());
+ $this->assertSame([1, 2], $everyone->entries->map->getId()->values()->all());
+ }
+
+ public function test_prefers_slots_featuring_highlighted_people_and_leaves_names_shown_in_untitled_slots_out_of_the_names(): void
+ {
+ $entries = new Collection([$this->entry(1, 'HEADLINER'), $this->entry(2, 'HEADLINER'), $this->entry(3, 'STANDARD')]);
+ $items = new Collection([
+ $this->item(10, 'Doors'),
+ $this->item(11, null, 3),
+ $this->item(12, null, 1, 3),
+ ...array_map(fn (int $id) => $this->item($id, "Set $id", 2), range(13, 18)),
+ ]);
+
+ $selection = (new LineupHighlightSelector)->select($entries, $items);
+
+ $this->assertSame([2, 3, 4, 5, 6], $selection->slots->keys()->all());
+ $this->assertSame([0, 2], $selection->slots->first()['people']->keys()->all());
+ $this->assertSame([2], $selection->entries->map->getId()->values()->all());
+ }
+
+ public function test_uses_any_slot_with_a_title_or_a_listed_person_when_none_feature_a_highlight(): void
+ {
+ $entries = new Collection([$this->entry(1, 'HEADLINER'), $this->entry(2, 'STANDARD')]);
+ $items = new Collection([
+ $this->item(10, null),
+ $this->item(11, null, 99),
+ $this->item(12, 'Doors'),
+ $this->item(13, null, 2),
+ ]);
+
+ $selection = (new LineupHighlightSelector)->select($entries, $items);
+
+ $this->assertSame([12, 13], $selection->slots->map(fn (array $slot) => $slot['item']->getId())->values()->all());
+ $this->assertSame([1], $selection->entries->map->getId()->values()->all());
+ }
+
+ private function entry(int $id, string $tier): EventLineupEntryDomainObject
+ {
+ return (new EventLineupEntryDomainObject)->setId($id)->setBillingTier($tier)->setIsHidden(false)
+ ->setProfile((new ProfileDomainObject)->setId($id)->setName("Act $id"));
+ }
+
+ private function item(int $id, ?string $title, int ...$entryIds): EventScheduleItemDomainObject
+ {
+ return (new EventScheduleItemDomainObject)->setId($id)->setTitle($title)
+ ->setPerformers(new Collection(array_map(
+ fn (int $entryId) => (new EventScheduleItemPerformerDomainObject)->setEventLineupEntryId($entryId),
+ $entryIds,
+ )));
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Mail/SendEventEmailMessagesServiceTest.php b/backend/tests/Unit/Services/Domain/Mail/SendEventEmailMessagesServiceTest.php
index 60d5847f0f..c938e03eb7 100644
--- a/backend/tests/Unit/Services/Domain/Mail/SendEventEmailMessagesServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Mail/SendEventEmailMessagesServiceTest.php
@@ -2,6 +2,14 @@
namespace Tests\Unit\Services\Domain\Mail;
+use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\Enums\MessageTypeEnum;
+use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\OrderDomainObject;
+use HiEvents\DomainObjects\Status\MessageStatus;
+use HiEvents\DomainObjects\UserDomainObject;
+use HiEvents\Jobs\Event\SendEventEmailJob;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\MessageRepositoryInterface;
@@ -11,6 +19,8 @@
use HiEvents\Services\Domain\Mail\SendEventEmailMessagesService;
use Illuminate\Contracts\Bus\Dispatcher;
use Mockery;
+use Mockery\MockInterface;
+use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionClass;
use ReflectionMethod;
use Symfony\Component\HttpKernel\Log\Logger;
@@ -18,6 +28,24 @@
class SendEventEmailMessagesServiceTest extends TestCase
{
+ private const SENDER_EMAIL = 'organizer@example.com';
+
+ private OrderRepositoryInterface|MockInterface $orderRepository;
+
+ private AttendeeRepositoryInterface|MockInterface $attendeeRepository;
+
+ private MessageRepositoryInterface|MockInterface $messageRepository;
+
+ private array $recipients = [];
+
+ private array $statusUpdates = [];
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
public function test_occurrence_filter_is_empty_for_a_message_queued_by_a_previous_release(): void
{
$messageData = (new ReflectionClass(SendMessageDTO::class))->newInstanceWithoutConstructor();
@@ -36,4 +64,161 @@ public function test_occurrence_filter_is_empty_for_a_message_queued_by_a_previo
$this->assertSame([], $occurrenceWhere->invoke($service, $messageData));
}
+
+ public static function messageTypes(): array
+ {
+ return [
+ 'individual attendees' => [MessageTypeEnum::INDIVIDUAL_ATTENDEES],
+ 'order owner' => [MessageTypeEnum::ORDER_OWNER],
+ 'ticket holders' => [MessageTypeEnum::TICKET_HOLDERS],
+ 'all attendees' => [MessageTypeEnum::ALL_ATTENDEES],
+ 'order owners with a product' => [MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT],
+ ];
+ }
+
+ #[DataProvider('messageTypes')]
+ public function test_test_message_only_emails_the_sender(MessageTypeEnum $type): void
+ {
+ $service = $this->makeService(
+ orders: [$this->makeOrder('buyer@example.com'), $this->makeOrder('buyer2@example.com')],
+ attendees: [$this->makeAttendee('attendee@example.com')],
+ );
+
+ $service->send($this->makeMessageData($type, isTest: true));
+
+ $this->assertSame([self::SENDER_EMAIL], $this->recipients);
+ $this->assertSame([MessageStatus::SENT->name], $this->statusUpdates);
+ }
+
+ public function test_test_message_to_order_owners_with_a_product_emails_the_sender_when_no_orders_match(): void
+ {
+ $service = $this->makeService(orders: [], attendees: []);
+
+ $service->send($this->makeMessageData(MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT, isTest: true));
+
+ $this->assertSame([self::SENDER_EMAIL], $this->recipients);
+ $this->assertSame([MessageStatus::SENT->name], $this->statusUpdates);
+ }
+
+ public function test_live_message_to_order_owners_with_a_product_emails_every_order_owner(): void
+ {
+ $service = $this->makeService(
+ orders: [$this->makeOrder('buyer@example.com'), $this->makeOrder('buyer2@example.com')],
+ attendees: [],
+ );
+
+ $service->send($this->makeMessageData(MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT, isTest: false));
+
+ $this->assertSame(['buyer@example.com', 'buyer2@example.com'], $this->recipients);
+ }
+
+ public function test_live_message_to_an_order_owner_emails_the_buyer_and_a_requested_copy_to_the_sender(): void
+ {
+ $service = $this->makeService(orders: [$this->makeOrder('buyer@example.com')], attendees: []);
+
+ $service->send($this->makeMessageData(MessageTypeEnum::ORDER_OWNER, isTest: false, sendCopy: true));
+
+ $this->assertSame([self::SENDER_EMAIL, 'buyer@example.com'], $this->recipients);
+ }
+
+ public function test_live_message_to_all_attendees_emails_each_attendee(): void
+ {
+ $service = $this->makeService(
+ orders: [],
+ attendees: [$this->makeAttendee('one@example.com'), $this->makeAttendee('two@example.com')],
+ );
+
+ $service->send($this->makeMessageData(MessageTypeEnum::ALL_ATTENDEES, isTest: false));
+
+ $this->assertSame(['one@example.com', 'two@example.com'], $this->recipients);
+ }
+
+ private function makeService(array $orders, array $attendees): SendEventEmailMessagesService
+ {
+ $event = (new EventDomainObject)
+ ->setId(10)
+ ->setEventSettings(new EventSettingDomainObject);
+
+ $eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $eventRepository->shouldReceive('findById')->with(10)->andReturn($event);
+
+ $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $this->orderRepository->shouldReceive('findFirstWhere')
+ ->andReturnUsing(fn (array $where) => $where['id'] !== null ? ($orders[0] ?? null) : null);
+ $this->orderRepository->shouldReceive('findOrdersAssociatedWithProducts')->andReturn(collect($orders));
+
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect($attendees));
+ $this->attendeeRepository->shouldReceive('findWhere')->andReturn(collect($attendees));
+
+ $this->messageRepository = Mockery::mock(MessageRepositoryInterface::class);
+ $this->messageRepository->shouldReceive('updateWhere')
+ ->andReturnUsing(function (array $attributes) {
+ $this->statusUpdates[] = $attributes['status'];
+
+ return 1;
+ });
+
+ $sender = (new UserDomainObject)
+ ->setId(99)
+ ->setEmail(self::SENDER_EMAIL)
+ ->setFirstName('Org')
+ ->setLastName('Anizer');
+
+ $userRepository = Mockery::mock(UserRepositoryInterface::class);
+ $userRepository->shouldReceive('findById')->with(99)->andReturn($sender);
+
+ $dispatcher = Mockery::mock(Dispatcher::class);
+ $dispatcher->shouldReceive('dispatch')
+ ->andReturnUsing(function (SendEventEmailJob $job) {
+ $this->recipients[] = (fn () => $this->email)->call($job);
+ });
+
+ return new SendEventEmailMessagesService(
+ $this->orderRepository,
+ $this->attendeeRepository,
+ $eventRepository,
+ $this->messageRepository,
+ $userRepository,
+ Mockery::mock(Logger::class),
+ $dispatcher,
+ );
+ }
+
+ private function makeMessageData(MessageTypeEnum $type, bool $isTest, bool $sendCopy = false): SendMessageDTO
+ {
+ return new SendMessageDTO(
+ account_id: 1,
+ event_id: 10,
+ subject: 'Subject',
+ message: 'Body
',
+ type: $type,
+ is_test: $isTest,
+ send_copy_to_current_user: $sendCopy,
+ sent_by_user_id: 99,
+ order_id: $type === MessageTypeEnum::ORDER_OWNER ? 5 : null,
+ order_statuses: ['COMPLETED'],
+ id: 1,
+ attendee_ids: [20],
+ product_ids: [30],
+ );
+ }
+
+ private function makeOrder(string $email): OrderDomainObject
+ {
+ return (new OrderDomainObject)
+ ->setId(5)
+ ->setEmail($email)
+ ->setFirstName('Buyer')
+ ->setLastName('Person');
+ }
+
+ private function makeAttendee(string $email): AttendeeDomainObject
+ {
+ return (new AttendeeDomainObject)
+ ->setEmail($email)
+ ->setFirstName('Attendee')
+ ->setLastName('Person');
+ }
}
diff --git a/backend/tests/Unit/Services/Domain/Mail/SendOrderDetailsServiceTest.php b/backend/tests/Unit/Services/Domain/Mail/SendOrderDetailsServiceTest.php
index 8b6c030fb2..f165ff1ba0 100644
--- a/backend/tests/Unit/Services/Domain/Mail/SendOrderDetailsServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Mail/SendOrderDetailsServiceTest.php
@@ -9,6 +9,7 @@
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\DomainObjects\Status\OrderStatus;
+use HiEvents\DomainObjects\UserDomainObject;
use HiEvents\Mail\Order\OrderSummary;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
@@ -65,7 +66,7 @@ public function test_order_notifications_go_to_the_organizer_and_opted_in_team_m
$teamRecipients->shouldReceive('recipientsFor')
->once()
->with(m::type(EventDomainObject::class), 'organizer@example.com')
- ->andReturn(['team@example.com']);
+ ->andReturn([$this->teamMember('team@example.com', 'en')]);
$this->service($order, notifyOrganizer: true, teamRecipients: $teamRecipients, recipients: $recipients)
->sendOrderSummaryAndTicketEmails($order);
@@ -80,7 +81,7 @@ public function test_team_members_are_notified_even_when_the_organizer_email_is_
$recipients = [];
$teamRecipients = m::mock(TeamOrderNotificationRecipientService::class);
- $teamRecipients->shouldReceive('recipientsFor')->once()->with(m::any(), null)->andReturn(['team@example.com']);
+ $teamRecipients->shouldReceive('recipientsFor')->once()->with(m::any(), null)->andReturn([$this->teamMember('team@example.com', 'en')]);
$this->service($order, notifyOrganizer: false, teamRecipients: $teamRecipients, recipients: $recipients)
->sendOrderSummaryAndTicketEmails($order);
@@ -89,6 +90,26 @@ public function test_team_members_are_notified_even_when_the_organizer_email_is_
$this->assertContains('team@example.com', $recipients);
}
+ public function test_team_notifications_are_sent_in_each_members_locale(): void
+ {
+ $order = $this->completedOrder()->setLocale('de');
+ $recipients = [];
+ $locales = [];
+
+ $teamRecipients = m::mock(TeamOrderNotificationRecipientService::class);
+ $teamRecipients->shouldReceive('recipientsFor')->once()->andReturn([
+ $this->teamMember('marie@example.com', 'fr'),
+ $this->teamMember('sam@example.com', 'en'),
+ ]);
+
+ $this->service($order, notifyOrganizer: false, teamRecipients: $teamRecipients, recipients: $recipients, locales: $locales)
+ ->sendOrderSummaryAndTicketEmails($order);
+
+ $this->assertSame('fr', $locales['marie@example.com']);
+ $this->assertSame('en', $locales['sam@example.com']);
+ $this->assertSame('de', $locales['buyer@example.com']);
+ }
+
public function test_manual_orders_notify_nobody_on_the_team(): void
{
$order = $this->completedOrder()->setIsManuallyCreated(true);
@@ -119,6 +140,7 @@ private function service(
bool $notifyOrganizer = false,
?TeamOrderNotificationRecipientService $teamRecipients = null,
array &$recipients = [],
+ array &$locales = [],
): SendOrderDetailsService {
$organizer = (new OrganizerDomainObject)->setId(1)->setEmail('organizer@example.com');
$event = (new EventDomainObject)
@@ -145,7 +167,11 @@ private function service(
return $mailer;
});
- $mailer->shouldReceive('locale')->andReturnSelf();
+ $mailer->shouldReceive('locale')->andReturnUsing(function ($locale) use (&$recipients, &$locales, &$mailer) {
+ $locales[end($recipients)] = $locale;
+
+ return $mailer;
+ });
$mailer->shouldReceive('send');
$mailBuilderService = m::mock(MailBuilderService::class);
@@ -161,6 +187,11 @@ private function service(
);
}
+ private function teamMember(string $email, string $locale): UserDomainObject
+ {
+ return (new UserDomainObject)->setEmail($email)->setLocale($locale);
+ }
+
private function attendee(int $id, ?string $email, AttendeeStatus $status = AttendeeStatus::ACTIVE): AttendeeDomainObject
{
return (new AttendeeDomainObject)
diff --git a/backend/tests/Unit/Services/Domain/Message/MessageDispatchServiceTest.php b/backend/tests/Unit/Services/Domain/Message/MessageDispatchServiceTest.php
index 7576815ff9..cd6ef8308c 100644
--- a/backend/tests/Unit/Services/Domain/Message/MessageDispatchServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Message/MessageDispatchServiceTest.php
@@ -128,7 +128,54 @@ public function test_message_without_occurrence_scope_is_dispatched(): void
Bus::assertDispatched(SendMessagesJob::class);
}
- private function makeMessage(?int $occurrenceId): MessageDomainObject
+ public function test_approved_test_message_is_dispatched_as_a_test(): void
+ {
+ $message = $this->makeMessage(occurrenceId: null, sendData: [
+ 'account_id' => 99,
+ 'send_copy_to_current_user' => false,
+ 'is_test' => true,
+ ]);
+
+ $this->messageRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ ['status' => MessageStatus::PROCESSING->name],
+ ['id' => 1, 'status' => MessageStatus::PENDING_REVIEW->name],
+ )
+ ->andReturn(1);
+
+ $this->service->dispatchMessage($message, MessageStatus::PENDING_REVIEW);
+
+ Bus::assertDispatched(SendMessagesJob::class, fn (SendMessagesJob $job) => $job->messageData->is_test === true);
+ }
+
+ public function test_test_message_with_json_send_data_is_dispatched_as_a_test(): void
+ {
+ $message = $this->makeMessage(occurrenceId: null, sendData: json_encode([
+ 'account_id' => 99,
+ 'is_test' => true,
+ ]));
+
+ $this->messageRepository->shouldReceive('updateWhere')->once()->andReturn(1);
+
+ $this->service->dispatchMessage($message);
+
+ Bus::assertDispatched(SendMessagesJob::class, fn (SendMessagesJob $job) => $job->messageData->is_test === true);
+ }
+
+ public function test_message_without_is_test_in_send_data_is_dispatched_as_live(): void
+ {
+ $message = $this->makeMessage(occurrenceId: null);
+
+ $this->messageRepository->shouldReceive('updateWhere')->once()->andReturn(1);
+
+ $this->service->dispatchMessage($message);
+
+ Bus::assertDispatched(SendMessagesJob::class, fn (SendMessagesJob $job) => $job->messageData->is_test === false);
+ }
+
+ private function makeMessage(?int $occurrenceId, array|string|null $sendData = null): MessageDomainObject
{
$message = new MessageDomainObject;
$message->setId(1);
@@ -138,7 +185,7 @@ private function makeMessage(?int $occurrenceId): MessageDomainObject
$message->setType('ALL_ATTENDEES');
$message->setSentByUserId(1);
$message->setEventOccurrenceId($occurrenceId);
- $message->setSendData([
+ $message->setSendData($sendData ?? [
'account_id' => 99,
'send_copy_to_current_user' => false,
]);
diff --git a/backend/tests/Unit/Services/Domain/Order/MarkOrderAsPaidServiceTest.php b/backend/tests/Unit/Services/Domain/Order/MarkOrderAsPaidServiceTest.php
index 6ca142a716..bfdadf4998 100644
--- a/backend/tests/Unit/Services/Domain/Order/MarkOrderAsPaidServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/MarkOrderAsPaidServiceTest.php
@@ -175,6 +175,32 @@ public function test_the_latest_invoice_is_marked_paid(): void
$this->service->markOrderAsPaid(self::ORDER_ID, self::EVENT_ID);
}
+ public function test_the_order_summary_email_attaches_the_paid_invoice(): void
+ {
+ $this->givenOrderAwaitingOfflinePayment(
+ pendingInvoice: (new InvoiceDomainObject)->setId(77)->setStatus(InvoiceStatus::UNPAID->name),
+ completedInvoice: (new InvoiceDomainObject)->setId(77)->setStatus(InvoiceStatus::PAID->name),
+ );
+ $this->givenEventWithoutOrganizerConfiguration();
+ $this->orderRepository->shouldReceive('updateFromArray')->once();
+ $this->attendeeRepository->shouldReceive('updateWhere')->once();
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+ $this->invoiceRepository->shouldReceive('findLatestInvoiceForOrder')
+ ->once()
+ ->with(self::ORDER_ID)
+ ->andReturn((new InvoiceDomainObject)->setId(77));
+ $this->invoiceRepository->shouldReceive('updateFromArray')
+ ->once()
+ ->with(77, ['status' => InvoiceStatus::PAID->name]);
+
+ $this->sendOrderDetailsService->shouldReceive('sendCustomerOrderSummary')
+ ->once()
+ ->withArgs(fn (OrderDomainObject $order, $event, $organizer, $eventSettings, ?InvoiceDomainObject $invoice) => $invoice?->getId() === 77
+ && $invoice->getStatus() === InvoiceStatus::PAID->name);
+
+ $this->service->markOrderAsPaid(self::ORDER_ID, self::EVENT_ID);
+ }
+
public function test_an_order_that_is_not_awaiting_offline_payment_cannot_be_marked_as_paid(): void
{
$this->givenOrderAwaitingOfflinePayment(status: OrderStatus::COMPLETED);
@@ -215,13 +241,16 @@ private function givenOrderAwaitingOfflinePayment(
?int $affiliateId = null,
float $totalGross = 50.0,
bool $occurrencePurchasable = true,
+ ?InvoiceDomainObject $pendingInvoice = null,
+ ?InvoiceDomainObject $completedInvoice = null,
): void {
$pending = (new OrderDomainObject)
->setId(self::ORDER_ID)
->setEventId(self::EVENT_ID)
->setStatus($status->name)
->setTotalGross($totalGross)
- ->setCurrency('USD');
+ ->setCurrency('USD')
+ ->setInvoices($pendingInvoice ? collect([$pendingInvoice]) : null);
$this->orderRepository->shouldReceive('findFirstWhere')
->once()
@@ -251,7 +280,8 @@ private function givenOrderAwaitingOfflinePayment(
->setStatus(OrderStatus::COMPLETED->name)
->setTotalGross($totalGross)
->setCurrency('USD')
- ->setAffiliateId($affiliateId);
+ ->setAffiliateId($affiliateId)
+ ->setInvoices($completedInvoice ? collect([$completedInvoice]) : null);
$this->orderRepository->shouldReceive('findById')
->once()
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandlerTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandlerTest.php
index de6ff4e3ea..0ec1c8081d 100644
--- a/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandlerTest.php
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandlerTest.php
@@ -3,24 +3,33 @@
namespace Tests\Unit\Services\Domain\Payment\Stripe\EventHandlers;
use HiEvents\DomainObjects\Enums\PaymentProviders;
+use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderRefundDomainObject;
+use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\Status\OrderRefundStatus;
use HiEvents\DomainObjects\StripePaymentDomainObject;
+use HiEvents\Mail\Order\OrderRefunded;
+use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface;
use HiEvents\Services\Domain\EventStatistics\EventStatisticsRefundService;
use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\ChargeRefundUpdatedHandler;
+use HiEvents\Services\Domain\Payment\Stripe\StripePaymentIntentRefundService;
use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent;
+use HiEvents\Services\Infrastructure\Lock\TransactionLockService;
use HiEvents\Values\MoneyValue;
+use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Database\DatabaseManager;
use Illuminate\Log\Logger;
use Mockery;
use Mockery\MockInterface;
+use PHPUnit\Framework\Attributes\DataProvider;
use Stripe\Refund;
use Tests\TestCase;
@@ -28,6 +37,10 @@ class ChargeRefundUpdatedHandlerTest extends TestCase
{
private const ORDER_ID = 500;
+ private const ORDER_SHORT_ID = 'o_refund_test';
+
+ private const EVENT_ID = 50;
+
private const PAYMENT_INTENT_ID = 'pi_test_123';
private const REFUND_ID = 're_test_456';
@@ -42,6 +55,12 @@ class ChargeRefundUpdatedHandlerTest extends TestCase
private DomainEventDispatcherService|MockInterface $domainEventDispatcherService;
+ private TransactionLockService|MockInterface $transactionLockService;
+
+ private EventRepositoryInterface|MockInterface $eventRepository;
+
+ private Mailer|MockInterface $mailer;
+
private ChargeRefundUpdatedHandler $handler;
protected function setUp(): void
@@ -53,6 +72,9 @@ protected function setUp(): void
$this->eventStatisticsRefundService = Mockery::mock(EventStatisticsRefundService::class);
$this->orderRefundRepository = Mockery::mock(OrderRefundRepositoryInterface::class);
$this->domainEventDispatcherService = Mockery::mock(DomainEventDispatcherService::class);
+ $this->transactionLockService = Mockery::mock(TransactionLockService::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->mailer = Mockery::mock(Mailer::class);
$databaseManager = Mockery::mock(DatabaseManager::class);
$databaseManager->shouldReceive('transaction')->andReturnUsing(fn (callable $callback) => $callback());
@@ -65,12 +87,16 @@ protected function setUp(): void
$this->eventStatisticsRefundService,
$this->orderRefundRepository,
$this->domainEventDispatcherService,
+ $this->transactionLockService,
+ $this->eventRepository,
+ $this->mailer,
);
}
public function test_a_partial_refund_records_the_amount_adjusts_statistics_and_marks_the_order_partially_refunded(): void
{
$this->givenStripePaymentForOrder();
+ $this->givenTheOrderIsLocked();
$this->givenNoExistingRefund();
$this->givenOrder(totalGross: 100.0, totalRefunded: 0.0);
@@ -100,20 +126,23 @@ public function test_a_partial_refund_records_the_amount_adjusts_statistics_and_
->with(Mockery::on(fn (OrderEvent $event) => $event->type === DomainEventType::ORDER_REFUNDED
&& $event->orderId === self::ORDER_ID));
- $this->handler->handleEvent($this->refund(amountMinor: 2500, status: 'succeeded'));
+ $this->mailer->shouldNotReceive('to');
+
+ $this->handler->handleEvent($this->refund(amountMinor: 2500, status: Refund::STATUS_SUCCEEDED));
$this->assertSame(self::ORDER_ID, $createdRefund['order_id']);
$this->assertSame(PaymentProviders::STRIPE->value, $createdRefund['payment_provider']);
$this->assertSame(self::REFUND_ID, $createdRefund['refund_id']);
$this->assertSame(25.0, $createdRefund['amount']);
$this->assertSame('USD', $createdRefund['currency']);
- $this->assertSame('succeeded', $createdRefund['status']);
+ $this->assertSame(Refund::STATUS_SUCCEEDED, $createdRefund['status']);
$this->assertSame(self::PAYMENT_INTENT_ID, $createdRefund['metadata']['payment_intent']);
}
public function test_a_refund_that_reaches_the_order_total_marks_the_order_fully_refunded(): void
{
$this->givenStripePaymentForOrder();
+ $this->givenTheOrderIsLocked();
$this->givenNoExistingRefund();
$this->givenOrder(totalGross: 100.0, totalRefunded: 60.0);
@@ -125,25 +154,53 @@ public function test_a_refund_that_reaches_the_order_total_marks_the_order_fully
$this->orderRefundRepository->shouldReceive('create')->once()->andReturn(new OrderRefundDomainObject);
$this->domainEventDispatcherService->shouldReceive('dispatch')->once();
- $this->handler->handleEvent($this->refund(amountMinor: 4000, status: 'succeeded'));
+ $this->handler->handleEvent($this->refund(amountMinor: 4000, status: Refund::STATUS_SUCCEEDED));
+ }
+
+ public function test_the_order_is_locked_before_the_refund_is_looked_up(): void
+ {
+ $calls = [];
+
+ $this->givenStripePaymentForOrder();
+ $this->orderRepository->shouldReceive('findById')->once()->with(self::ORDER_ID)->andReturn($this->order(100.0, 0.0));
+ $this->transactionLockService->shouldReceive('lockOrder')
+ ->once()
+ ->with(self::ORDER_SHORT_ID)
+ ->andReturnUsing(function () use (&$calls) {
+ $calls[] = 'lock';
+ });
+ $this->orderRefundRepository->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['refund_id' => self::REFUND_ID])
+ ->andReturnUsing(function () use (&$calls) {
+ $calls[] = 'find-refund';
+
+ return (new OrderRefundDomainObject)->setId(1)->setRefundId(self::REFUND_ID);
+ });
+
+ $this->handler->handleEvent($this->refund(amountMinor: 2500, status: Refund::STATUS_SUCCEEDED));
+
+ $this->assertSame(['lock', 'find-refund'], $calls);
}
- public function test_a_replayed_refund_webhook_is_ignored(): void
+ public function test_a_replayed_refund_webhook_is_ignored_and_does_not_email_again(): void
{
$this->givenStripePaymentForOrder();
+ $this->givenTheOrderIsLocked();
$this->orderRefundRepository->shouldReceive('findFirstWhere')
->once()
->with(['refund_id' => self::REFUND_ID])
->andReturn((new OrderRefundDomainObject)->setId(1)->setRefundId(self::REFUND_ID));
+ $this->orderRepository->shouldReceive('findById')->once()->with(self::ORDER_ID)->andReturn($this->order(100.0, 0.0));
- $this->orderRepository->shouldNotReceive('findById');
$this->orderRepository->shouldNotReceive('increment');
$this->orderRepository->shouldNotReceive('updateFromArray');
$this->eventStatisticsRefundService->shouldNotReceive('updateForRefund');
$this->orderRefundRepository->shouldNotReceive('create');
$this->domainEventDispatcherService->shouldNotReceive('dispatch');
+ $this->mailer->shouldNotReceive('to');
- $this->handler->handleEvent($this->refund(amountMinor: 2500, status: 'succeeded'));
+ $this->handler->handleEvent($this->refund(amountMinor: 2500, status: Refund::STATUS_SUCCEEDED, notifyBuyer: true));
}
public function test_a_refund_for_an_unknown_payment_intent_is_ignored(): void
@@ -153,16 +210,59 @@ public function test_a_refund_for_an_unknown_payment_intent_is_ignored(): void
->with(['payment_intent_id' => self::PAYMENT_INTENT_ID])
->andReturnNull();
+ $this->transactionLockService->shouldNotReceive('lockOrder');
$this->orderRefundRepository->shouldNotReceive('findFirstWhere');
$this->orderRepository->shouldNotReceive('findById');
$this->eventStatisticsRefundService->shouldNotReceive('updateForRefund');
- $this->handler->handleEvent($this->refund(amountMinor: 2500, status: 'succeeded'));
+ $this->handler->handleEvent($this->refund(amountMinor: 2500, status: Refund::STATUS_SUCCEEDED));
}
- public function test_a_failed_refund_marks_the_order_refund_failed_without_touching_statistics(): void
+ public static function stillProcessingStatuses(): array
+ {
+ return [
+ 'pending' => [Refund::STATUS_PENDING, 'insufficient_funds'],
+ 'pending without a reason' => [Refund::STATUS_PENDING, null],
+ 'requires_action' => [Refund::STATUS_REQUIRES_ACTION, null],
+ ];
+ }
+
+ #[DataProvider('stillProcessingStatuses')]
+ public function test_a_refund_still_processing_leaves_the_order_untouched(string $status, ?string $pendingReason): void
{
$this->givenStripePaymentForOrder();
+ $this->givenTheOrderIsLocked();
+ $this->givenNoExistingRefund();
+ $this->orderRepository->shouldReceive('findById')->once()->with(self::ORDER_ID)->andReturn($this->order(100.0, 0.0));
+
+ $this->orderRepository->shouldNotReceive('updateFromArray');
+ $this->orderRepository->shouldNotReceive('increment');
+ $this->eventStatisticsRefundService->shouldNotReceive('updateForRefund');
+ $this->orderRefundRepository->shouldNotReceive('create');
+ $this->domainEventDispatcherService->shouldNotReceive('dispatch');
+ $this->mailer->shouldNotReceive('to');
+
+ $this->handler->handleEvent($this->refund(
+ amountMinor: 2500,
+ status: $status,
+ notifyBuyer: true,
+ pendingReason: $pendingReason,
+ ));
+ }
+
+ public static function finishedFailureStatuses(): array
+ {
+ return [
+ 'failed' => [Refund::STATUS_FAILED],
+ 'canceled' => [Refund::STATUS_CANCELED],
+ ];
+ }
+
+ #[DataProvider('finishedFailureStatuses')]
+ public function test_a_finished_failure_marks_the_order_refund_failed_and_records_the_attempt(string $status): void
+ {
+ $this->givenStripePaymentForOrder();
+ $this->givenTheOrderIsLocked();
$this->givenNoExistingRefund();
$this->givenOrder(totalGross: 100.0, totalRefunded: 0.0);
@@ -170,15 +270,95 @@ public function test_a_failed_refund_marks_the_order_refund_failed_without_touch
->once()
->with(self::ORDER_ID, [OrderDomainObjectAbstract::REFUND_STATUS => OrderRefundStatus::REFUND_FAILED->name]);
+ $createdRefund = null;
+ $this->orderRefundRepository->shouldReceive('create')
+ ->once()
+ ->andReturnUsing(function (array $attributes) use (&$createdRefund) {
+ $createdRefund = $attributes;
+
+ return new OrderRefundDomainObject;
+ });
+
$this->orderRepository->shouldNotReceive('increment');
$this->eventStatisticsRefundService->shouldNotReceive('updateForRefund');
- $this->orderRefundRepository->shouldNotReceive('create');
$this->domainEventDispatcherService->shouldNotReceive('dispatch');
+ $this->mailer->shouldNotReceive('to');
+
+ $this->handler->handleEvent($this->refund(amountMinor: 2500, status: $status, notifyBuyer: true));
- $this->handler->handleEvent($this->refund(amountMinor: 2500, status: 'failed'));
+ $this->assertSame(self::REFUND_ID, $createdRefund['refund_id']);
+ $this->assertSame($status, $createdRefund['status']);
+ $this->assertSame(25.0, $createdRefund['amount']);
}
- private function refund(int $amountMinor, string $status): Refund
+ public function test_a_pending_refund_that_later_succeeds_is_recorded_once(): void
+ {
+ $recorded = null;
+
+ $this->stripePaymentsRepository->shouldReceive('findFirstWhere')
+ ->times(3)
+ ->with(['payment_intent_id' => self::PAYMENT_INTENT_ID])
+ ->andReturn((new StripePaymentDomainObject)->setOrderId(self::ORDER_ID));
+ $this->transactionLockService->shouldReceive('lockOrder')->times(3)->with(self::ORDER_SHORT_ID);
+ $this->orderRepository->shouldReceive('findById')->with(self::ORDER_ID)->andReturn($this->order(100.0, 0.0));
+ $this->orderRefundRepository->shouldReceive('findFirstWhere')
+ ->times(3)
+ ->with(['refund_id' => self::REFUND_ID])
+ ->andReturnUsing(function () use (&$recorded) {
+ return $recorded;
+ });
+ $this->orderRefundRepository->shouldReceive('create')
+ ->once()
+ ->andReturnUsing(function () use (&$recorded) {
+ return $recorded = (new OrderRefundDomainObject)->setId(1)->setRefundId(self::REFUND_ID);
+ });
+
+ $this->orderRepository->shouldReceive('increment')->once()->with(self::ORDER_ID, OrderDomainObjectAbstract::TOTAL_REFUNDED, 25.0);
+ $this->orderRepository->shouldReceive('updateFromArray')
+ ->once()
+ ->with(self::ORDER_ID, [OrderDomainObjectAbstract::REFUND_STATUS => OrderRefundStatus::PARTIALLY_REFUNDED->name]);
+ $this->eventStatisticsRefundService->shouldReceive('updateForRefund')->once();
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+ $this->givenTheBuyerIsEmailed();
+
+ $this->handler->handleEvent($this->refund(amountMinor: 2500, status: Refund::STATUS_PENDING, notifyBuyer: true));
+ $this->handler->handleEvent($this->refund(amountMinor: 2500, status: Refund::STATUS_SUCCEEDED, notifyBuyer: true));
+ $this->handler->handleEvent($this->refund(amountMinor: 2500, status: Refund::STATUS_SUCCEEDED, notifyBuyer: true));
+ }
+
+ public function test_a_succeeded_refund_requested_with_notify_buyer_emails_the_buyer_the_refunded_amount(): void
+ {
+ $this->givenASuccessfulRefundIsRecorded(email: 'buyer@example.com', locale: 'fr');
+
+ $sentMail = $this->givenTheBuyerIsEmailed(email: 'buyer@example.com', locale: 'fr');
+
+ $this->handler->handleEvent($this->refund(amountMinor: 2550, status: Refund::STATUS_SUCCEEDED, notifyBuyer: true));
+
+ $this->assertSame(2550, $sentMail->mail->content()->with['refundAmount']->toMinorUnit());
+ $this->assertSame('USD', $sentMail->mail->content()->with['refundAmount']->getMoney()->getCurrency()->getCurrencyCode());
+ }
+
+ public function test_a_succeeded_refund_without_the_notify_flag_does_not_email_the_buyer(): void
+ {
+ $this->givenASuccessfulRefundIsRecorded(email: 'buyer@example.com', locale: 'en');
+
+ $this->eventRepository->shouldNotReceive('findById');
+ $this->mailer->shouldNotReceive('to');
+
+ $this->handler->handleEvent($this->refund(amountMinor: 2500, status: Refund::STATUS_SUCCEEDED));
+ }
+
+ public function test_a_succeeded_refund_for_an_order_without_an_email_does_not_email_anyone(): void
+ {
+ $this->givenASuccessfulRefundIsRecorded(email: null, locale: 'en');
+
+ $this->eventRepository->shouldNotReceive('findById');
+ $this->mailer->shouldNotReceive('to');
+
+ $this->handler->handleEvent($this->refund(amountMinor: 2500, status: Refund::STATUS_SUCCEEDED, notifyBuyer: true));
+ }
+
+ private function refund(int $amountMinor, string $status, bool $notifyBuyer = false, ?string $pendingReason = null): Refund
{
return Refund::constructFrom([
'id' => self::REFUND_ID,
@@ -187,7 +367,8 @@ private function refund(int $amountMinor, string $status): Refund
'currency' => 'usd',
'payment_intent' => self::PAYMENT_INTENT_ID,
'status' => $status,
- 'metadata' => [],
+ 'pending_reason' => $pendingReason,
+ 'metadata' => $notifyBuyer ? [StripePaymentIntentRefundService::NOTIFY_BUYER_METADATA_KEY => '1'] : [],
]);
}
@@ -199,6 +380,11 @@ private function givenStripePaymentForOrder(): void
->andReturn((new StripePaymentDomainObject)->setOrderId(self::ORDER_ID));
}
+ private function givenTheOrderIsLocked(): void
+ {
+ $this->transactionLockService->shouldReceive('lockOrder')->once()->with(self::ORDER_SHORT_ID);
+ }
+
private function givenNoExistingRefund(): void
{
$this->orderRefundRepository->shouldReceive('findFirstWhere')
@@ -207,15 +393,66 @@ private function givenNoExistingRefund(): void
->andReturnNull();
}
- private function givenOrder(float $totalGross, float $totalRefunded): void
+ private function givenOrder(float $totalGross, float $totalRefunded, ?string $email = null, string $locale = 'en'): void
{
$this->orderRepository->shouldReceive('findById')
- ->once()
+ ->twice()
->with(self::ORDER_ID)
- ->andReturn((new OrderDomainObject)
- ->setId(self::ORDER_ID)
- ->setCurrency('USD')
- ->setTotalGross($totalGross)
- ->setTotalRefunded($totalRefunded));
+ ->andReturn($this->order($totalGross, $totalRefunded, $email, $locale));
+ }
+
+ private function order(float $totalGross, float $totalRefunded, ?string $email = 'buyer@example.com', string $locale = 'en'): OrderDomainObject
+ {
+ return (new OrderDomainObject)
+ ->setId(self::ORDER_ID)
+ ->setShortId(self::ORDER_SHORT_ID)
+ ->setEventId(self::EVENT_ID)
+ ->setCurrency('USD')
+ ->setEmail($email)
+ ->setLocale($locale)
+ ->setTotalGross($totalGross)
+ ->setTotalRefunded($totalRefunded);
+ }
+
+ private function givenASuccessfulRefundIsRecorded(?string $email, string $locale): void
+ {
+ $this->givenStripePaymentForOrder();
+ $this->givenTheOrderIsLocked();
+ $this->givenNoExistingRefund();
+ $this->givenOrder(totalGross: 100.0, totalRefunded: 0.0, email: $email, locale: $locale);
+
+ $this->orderRepository->shouldReceive('increment')->once();
+ $this->orderRepository->shouldReceive('updateFromArray')->once();
+ $this->eventStatisticsRefundService->shouldReceive('updateForRefund')->once();
+ $this->orderRefundRepository->shouldReceive('create')->once()->andReturn(new OrderRefundDomainObject);
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+ }
+
+ private function givenTheBuyerIsEmailed(string $email = 'buyer@example.com', string $locale = 'en'): object
+ {
+ $sent = new class
+ {
+ public ?OrderRefunded $mail = null;
+ };
+
+ $event = (new EventDomainObject)
+ ->setId(self::EVENT_ID)
+ ->setOrganizer(new OrganizerDomainObject)
+ ->setEventSettings(new EventSettingDomainObject);
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->once()->with(self::EVENT_ID)->andReturn($event);
+
+ $this->mailer->shouldReceive('to')->once()->with($email)->andReturnSelf();
+ $this->mailer->shouldReceive('locale')->once()->with($locale)->andReturnSelf();
+ $this->mailer->shouldReceive('send')
+ ->once()
+ ->with(Mockery::on(function (OrderRefunded $mail) use ($sent) {
+ $sent->mail = $mail;
+
+ return true;
+ }));
+
+ return $sent;
}
}
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripePaymentIntentRefundServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripePaymentIntentRefundServiceTest.php
index eb5cbb8c10..ceb6d3e853 100644
--- a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripePaymentIntentRefundServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripePaymentIntentRefundServiceTest.php
@@ -14,6 +14,8 @@
class StripePaymentIntentRefundServiceTest extends TestCase
{
+ private array $requests = [];
+
protected function tearDown(): void
{
Mockery::close();
@@ -22,33 +24,102 @@ protected function tearDown(): void
public function test_idempotency_key_is_derived_from_the_payment_intent_not_the_local_row_id(): void
{
+ $refund = $this->refund(amount: 12.50, alreadyRefunded: 0.0, failedRefundCount: 0);
+
+ $this->assertSame('re_test', $refund->id);
+ $this->assertSame('pi_test_abc123', $this->requests[0]['params']['payment_intent']);
+ $this->assertSame(1250, $this->requests[0]['params']['amount']);
+ $this->assertSame('refund_pi_test_abc123_amount_1250', $this->requests[0]['opts']['idempotency_key']);
+ }
+
+ public function test_a_second_refund_of_the_same_amount_gets_a_new_idempotency_key_once_the_first_is_recorded(): void
+ {
+ $this->refund(amount: 12.50, alreadyRefunded: 0.0, failedRefundCount: 0);
+ $this->refund(amount: 12.50, alreadyRefunded: 12.50, failedRefundCount: 0);
+
+ $this->assertNotSame($this->requests[0]['opts']['idempotency_key'], $this->requests[1]['opts']['idempotency_key']);
+ $this->assertSame($this->requests[0]['params'], $this->requests[1]['params']);
+ }
+
+ public function test_retrying_a_refund_that_failed_gets_a_new_idempotency_key(): void
+ {
+ $this->refund(amount: 12.50, alreadyRefunded: 0.0, failedRefundCount: 0);
+ $this->refund(amount: 12.50, alreadyRefunded: 0.0, failedRefundCount: 1);
+ $this->refund(amount: 12.50, alreadyRefunded: 0.0, failedRefundCount: 2);
+
+ $keys = array_map(fn (array $request) => $request['opts']['idempotency_key'], $this->requests);
+
+ $this->assertCount(3, array_unique($keys));
+ }
+
+ public function test_retrying_the_same_refund_reuses_the_idempotency_key(): void
+ {
+ $this->refund(amount: 12.50, alreadyRefunded: 20.0, failedRefundCount: 1);
+ $this->refund(amount: 12.50, alreadyRefunded: 20.0, failedRefundCount: 1);
+
+ $this->assertSame($this->requests[0]['opts']['idempotency_key'], $this->requests[1]['opts']['idempotency_key']);
+ $this->assertSame($this->requests[0]['params'], $this->requests[1]['params']);
+ }
+
+ public function test_the_buyer_notification_request_travels_with_the_refund_as_metadata(): void
+ {
+ $this->refund(amount: 12.50, alreadyRefunded: 0.0, failedRefundCount: 0, notifyBuyer: true);
+
+ $this->assertSame(
+ [StripePaymentIntentRefundService::NOTIFY_BUYER_METADATA_KEY => '1'],
+ $this->requests[0]['params']['metadata'],
+ );
+ }
+
+ public function test_no_metadata_is_sent_when_the_buyer_is_not_to_be_notified(): void
+ {
+ $this->refund(amount: 12.50, alreadyRefunded: 0.0, failedRefundCount: 0);
+
+ $this->assertArrayNotHasKey('metadata', $this->requests[0]['params']);
+ }
+
+ public function test_the_refund_is_created_on_the_connected_account_in_saas_mode(): void
+ {
+ $this->refund(amount: 12.50, alreadyRefunded: 0.0, failedRefundCount: 0, saasMode: true);
+
+ $this->assertSame('acct_test', $this->requests[0]['opts']['stripe_account']);
+ }
+
+ private function refund(
+ float $amount,
+ float $alreadyRefunded,
+ int $failedRefundCount,
+ bool $notifyBuyer = false,
+ bool $saasMode = false,
+ ): Refund {
$payment = (new StripePaymentDomainObject)
->setId(1)
- ->setPaymentIntentId('pi_test_abc123');
+ ->setPaymentIntentId('pi_test_abc123')
+ ->setConnectedAccountId('acct_test');
$refunds = Mockery::mock(RefundService::class);
$refunds->shouldReceive('create')
->once()
- ->withArgs(function (array $params, array $opts) {
- return $params['payment_intent'] === 'pi_test_abc123'
- && $params['amount'] === 1250
- && $opts['idempotency_key'] === 'refund_pi_test_abc123_amount_1250';
- })
- ->andReturn(Refund::constructFrom(['id' => 're_test']));
+ ->andReturnUsing(function (array $params, array $opts) {
+ $this->requests[] = ['params' => $params, 'opts' => $opts];
+
+ return Refund::constructFrom(['id' => 're_test']);
+ });
$stripeClient = Mockery::mock(StripeClient::class);
$stripeClient->shouldReceive('getService')->with('refunds')->andReturn($refunds);
$service = new StripePaymentIntentRefundService(
- new Repository(['app' => ['saas_mode_enabled' => false]]),
+ new Repository(['app' => ['saas_mode_enabled' => $saasMode]]),
);
- $refund = $service->refundPayment(
- MoneyValue::fromFloat(12.50, 'USD'),
- $payment,
- $stripeClient,
+ return $service->refundPayment(
+ amount: MoneyValue::fromFloat($amount, 'USD'),
+ alreadyRefunded: MoneyValue::fromFloat($alreadyRefunded, 'USD'),
+ failedRefundCount: $failedRefundCount,
+ payment: $payment,
+ stripeClient: $stripeClient,
+ notifyBuyer: $notifyBuyer,
);
-
- $this->assertSame('re_test', $refund->id);
}
}
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderServiceTest.php
index ca7b84ae2f..437f897ccc 100644
--- a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderServiceTest.php
@@ -16,6 +16,7 @@
use HiEvents\Services\Domain\Payment\Stripe\StripePaymentIntentRefundService;
use HiEvents\Services\Domain\Payment\Stripe\StripeRefundExpiredOrderService;
use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory;
+use HiEvents\Values\MoneyValue;
use Illuminate\Contracts\Mail\Mailer;
use Mockery;
use Psr\Log\LoggerInterface;
@@ -105,6 +106,67 @@ public function test_buyer_notification_is_sent_before_commit_so_it_survives_the
$this->assertFalse($capturedMail->afterCommit, 'Refund notification must dispatch beforeCommit or the rollback discards it');
}
+ public function test_the_expired_order_refund_keeps_a_fixed_idempotency_key_whatever_the_order_has_refunded(): void
+ {
+ $order = (new OrderDomainObject)->setCurrency('USD')
+ ->setId(1)
+ ->setEventId(2)
+ ->setEmail(null)
+ ->setTotalRefunded(4.0);
+
+ $stripePayment = (new StripePaymentDomainObject)
+ ->setOrderId(1)
+ ->setPaymentIntentId('pi_test');
+
+ $eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $eventRepository->shouldReceive('findById')->with(2)->andReturn((new EventDomainObject)->setId(2));
+
+ $stripeClient = Mockery::mock(StripeClient::class);
+ $clientFactory = Mockery::mock(StripeClientFactory::class);
+ $clientFactory->shouldReceive('createForPlatform')->andReturn($stripeClient);
+
+ $request = null;
+ $refundService = Mockery::mock(StripePaymentIntentRefundService::class);
+ $refundService->shouldReceive('refundPayment')
+ ->once()
+ ->andReturnUsing(function (
+ MoneyValue $amount,
+ MoneyValue $alreadyRefunded,
+ int $failedRefundCount,
+ StripePaymentDomainObject $payment,
+ StripeClient $client,
+ bool $notifyBuyer,
+ ) use (&$request) {
+ $request = compact('amount', 'alreadyRefunded', 'failedRefundCount', 'notifyBuyer');
+
+ return Refund::constructFrom(['id' => 're_test', 'status' => 'succeeded']);
+ });
+
+ $logger = Mockery::mock(LoggerInterface::class);
+ $logger->shouldReceive('info');
+
+ $service = new StripeRefundExpiredOrderService(
+ $refundService,
+ Mockery::mock(Mailer::class),
+ $logger,
+ $eventRepository,
+ $clientFactory,
+ Mockery::mock(OrderRefundRepositoryInterface::class),
+ );
+
+ $service->refundExpiredOrder(
+ paymentIntent: PaymentIntent::constructFrom(['id' => 'pi_test', 'amount' => 1000, 'currency' => 'usd']),
+ stripePayment: $stripePayment,
+ order: $order,
+ );
+
+ $this->assertSame(1000, $request['amount']->toMinorUnit());
+ $this->assertSame(0, $request['alreadyRefunded']->toMinorUnit());
+ $this->assertSame(0, $request['failedRefundCount']);
+ $this->assertFalse($request['notifyBuyer']);
+ }
+
public function test_recording_the_refund_writes_the_order_refund_row(): void
{
$written = null;
diff --git a/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceDisplayPricingTest.php b/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceDisplayPricingTest.php
new file mode 100644
index 0000000000..169184fda1
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceDisplayPricingTest.php
@@ -0,0 +1,458 @@
+overridePrices = [100 => 45.00];
+ $product = $this->createPaidProduct(price: 30.00);
+
+ $this->filter($product, $this->percentagePromo(10), self::OCCURRENCE_ID);
+
+ $price = $product->getProductPrices()->first();
+ $this->assertSame(40.50, $price->getPrice());
+ $this->assertSame(45.00, $price->getPriceBeforeDiscount());
+ }
+
+ public function test_a_promo_without_an_override_strikes_the_base_price(): void
+ {
+ $product = $this->createPaidProduct(price: 30.00);
+
+ $this->filter($product, $this->percentagePromo(10), self::OCCURRENCE_ID);
+
+ $price = $product->getProductPrices()->first();
+ $this->assertSame(27.00, $price->getPrice());
+ $this->assertSame(30.00, $price->getPriceBeforeDiscount());
+ }
+
+ public function test_a_promo_on_a_tiered_product_strikes_each_tiers_own_date_price(): void
+ {
+ $this->overridePrices = [101 => 60.00];
+ $product = (new ProductDomainObject)
+ ->setId(1)
+ ->setEventId(self::EVENT_ID)
+ ->setType(ProductPriceType::TIERED->name)
+ ->setProductType(ProductType::TICKET->name)
+ ->setTitle('Ticket')
+ ->setProductPrices(collect([
+ (new ProductPriceDomainObject)->setId(100)->setOrder(1)->setLabel('Early')->setPrice(20.00),
+ (new ProductPriceDomainObject)->setId(101)->setOrder(2)->setLabel('Late')->setPrice(25.00),
+ ]));
+
+ $this->filter($product, $this->fixedPromo(5.00), self::OCCURRENCE_ID);
+
+ [$early, $late] = $product->getProductPrices()->values()->all();
+ $this->assertSame(15.00, $early->getPrice());
+ $this->assertSame(20.00, $early->getPriceBeforeDiscount());
+ $this->assertSame(55.00, $late->getPrice());
+ $this->assertSame(60.00, $late->getPriceBeforeDiscount());
+ }
+
+ public function test_an_override_without_a_promo_is_not_marked_as_discounted(): void
+ {
+ $this->overridePrices = [100 => 45.00];
+ $product = $this->createPaidProduct(price: 30.00);
+
+ $this->filter($product, null, self::OCCURRENCE_ID);
+
+ $price = $product->getProductPrices()->first();
+ $this->assertSame(45.00, $price->getPrice());
+ $this->assertNull($price->getPriceBeforeDiscount());
+ }
+
+ public function test_a_discounted_override_with_vat_and_a_fixed_fee_publishes_the_display_amounts(): void
+ {
+ $this->overridePrices = [100 => 12.50];
+ $product = $this->createPaidProduct(price: 10.00, taxesAndFees: [
+ $this->fee(TaxCalculationType::FIXED, 1.50),
+ $this->tax(TaxCalculationType::PERCENTAGE, 23),
+ ]);
+
+ $this->filter($product, $this->percentagePromo(20), self::OCCURRENCE_ID);
+
+ $price = $product->getProductPrices()->first();
+ $this->assertSame(10.00, $price->getPrice());
+ $this->assertSame(12.50, $price->getPriceBeforeDiscount());
+ $this->assertSame(1.50, $price->getFeeTotal());
+ $this->assertSame(2.65, $price->getTaxTotal());
+ $this->assertSame(14.15, $price->getPriceIncludingTaxAndServiceFee());
+ $this->assertSame(17.22, $price->getPriceBeforeDiscountIncludingTaxesAndFees());
+ }
+
+ public function test_a_promo_code_without_a_discount_type_publishes_no_struck_price(): void
+ {
+ $product = $this->createPaidProduct(price: 20.00, taxesAndFees: [
+ $this->tax(TaxCalculationType::PERCENTAGE, 23),
+ ]);
+ $promoCode = (new PromoCodeDomainObject)
+ ->setDiscountType(PromoCodeDiscountTypeEnum::NONE->name)
+ ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::EACH_PRODUCT->name)
+ ->setDiscount(10);
+
+ $this->filter($product, $promoCode, null, $this->cloudDefaultEvent(), $this->cloudPlatformFeeService());
+
+ $price = $product->getProductPrices()->first();
+ $this->assertSame(20.00, $price->getPrice());
+ $this->assertNull($price->getPriceBeforeDiscount());
+ $this->assertNull($price->getPriceBeforeDiscountIncludingTaxesAndFees());
+ $this->assertSame(24.97, $price->getPriceIncludingTaxAndServiceFee());
+ }
+
+ public function test_a_full_discount_on_a_cloud_default_event_strikes_the_full_inclusive_price(): void
+ {
+ $product = $this->createPaidProduct(price: 20.00, taxesAndFees: [
+ $this->tax(TaxCalculationType::PERCENTAGE, 23),
+ ]);
+
+ $this->filter($product, $this->percentagePromo(100), null, $this->cloudDefaultEvent(), $this->cloudPlatformFeeService());
+
+ $price = $product->getProductPrices()->first();
+ $this->assertSame(0.00, $price->getPrice());
+ $this->assertSame(0.00, $price->getPriceIncludingTaxAndServiceFee());
+ $this->assertSame(20.00, $price->getPriceBeforeDiscount());
+ $this->assertSame(24.97, $price->getPriceBeforeDiscountIncludingTaxesAndFees());
+ }
+
+ public function test_checkout_charges_the_same_discounted_override_the_page_displays(): void
+ {
+ $this->overridePrices = [100 => 12.50];
+ $product = $this->createPaidProduct(price: 10.00, taxesAndFees: [
+ $this->fee(TaxCalculationType::FIXED, 1.50),
+ $this->tax(TaxCalculationType::PERCENTAGE, 23),
+ ]);
+
+ $this->processOrder($product, $this->percentagePromo(20), quantity: 3, eventOccurrenceId: self::OCCURRENCE_ID);
+
+ [$line] = $this->capturedOrderItems;
+ $this->assertSame(10.00, $line['price']);
+ $this->assertSame(12.50, $line['price_before_discount']);
+ $this->assertSame(30.00, $line['total_before_additions']);
+ $this->assertSame(42.44, $line['total_gross']);
+ }
+
+ #[DataProvider('awkwardVatOrders')]
+ public function test_vat_inclusive_display_and_order_totals_for_awkward_prices(
+ ?PromoCodeDomainObject $promoCode,
+ float $displayedPrice,
+ float $displayedTax,
+ float $orderTotalGross,
+ ): void {
+ $product = $this->createPaidProduct(price: 9.99, taxesAndFees: [
+ $this->tax(TaxCalculationType::PERCENTAGE, 23),
+ ]);
+
+ $this->filter($product, $promoCode, null);
+
+ $price = $product->getProductPrices()->first();
+ $this->assertSame($displayedPrice, $price->getPrice());
+ $this->assertSame($displayedTax, $price->getTaxTotal());
+
+ $this->processOrder($this->createPaidProduct(price: 9.99, taxesAndFees: [
+ $this->tax(TaxCalculationType::PERCENTAGE, 23),
+ ]), $promoCode, quantity: 3);
+
+ [$line] = $this->capturedOrderItems;
+ $this->assertSame($displayedPrice, $line['price']);
+ $this->assertSame($orderTotalGross, $line['total_gross']);
+ }
+
+ public static function awkwardVatOrders(): array
+ {
+ return [
+ 'no promo' => [null, 9.99, 2.30, 36.86],
+ '15% promo' => [self::percentagePromo(15), 8.49, 1.95, 31.33],
+ '1.33 fixed promo' => [self::fixedPromo(1.33), 8.66, 1.99, 31.96],
+ ];
+ }
+
+ #[DataProvider('cloudDefaultPlatformFeePrices')]
+ public function test_a_cloud_default_event_publishes_the_platform_fee_in_the_fee_total_only(
+ ?float $vatRate,
+ ?PromoCodeDomainObject $promoCode,
+ float $displayedPrice,
+ ?float $priceBeforeDiscount,
+ float $displayedTax,
+ float $displayedFee,
+ float $displayedInclusivePrice,
+ ?float $priceBeforeDiscountIncludingTaxesAndFees,
+ ): void {
+ $product = $this->createPaidProduct(
+ price: 20.00,
+ taxesAndFees: $vatRate === null ? [] : [$this->tax(TaxCalculationType::PERCENTAGE, $vatRate)],
+ );
+
+ $this->filter($product, $promoCode, null, $this->cloudDefaultEvent(), $this->cloudPlatformFeeService());
+
+ $price = $product->getProductPrices()->first();
+ $this->assertSame($displayedPrice, $price->getPrice());
+ $this->assertSame($priceBeforeDiscount, $price->getPriceBeforeDiscount());
+ $this->assertSame($displayedTax, $price->getTaxTotal());
+ $this->assertSame($displayedFee, $price->getFeeTotal());
+ $this->assertSame($displayedInclusivePrice, $price->getPriceIncludingTaxAndServiceFee());
+ $this->assertSame($priceBeforeDiscountIncludingTaxesAndFees, $price->getPriceBeforeDiscountIncludingTaxesAndFees());
+
+ $platformFee = $product->getTaxAndFees()
+ ->first(fn (TaxAndFeesDomainObject $taxOrFee) => $taxOrFee->getId() === OrderPlatformFeePassThroughService::PLATFORM_FEE_ID);
+ $this->assertNotNull($platformFee);
+ $this->assertSame(TaxType::FEE->name, $platformFee->getType());
+ $this->assertSame(TaxCalculationType::FIXED->name, $platformFee->getCalculationType());
+ $this->assertEquals(0, $platformFee->getRate());
+ }
+
+ public static function cloudDefaultPlatformFeePrices(): array
+ {
+ return [
+ 'no tax, 10% promo' => [null, self::percentagePromo(10), 18.00, 20.00, 0.00, 0.27, 18.27, 20.30],
+ 'no tax, no promo' => [null, null, 20.00, null, 0.00, 0.30, 20.30, null],
+ '23% VAT, 10% promo' => [23.0, self::percentagePromo(10), 18.00, 20.00, 4.14, 0.34, 22.48, 24.97],
+ '23% VAT, no promo' => [23.0, null, 20.00, null, 4.60, 0.37, 24.97, null],
+ ];
+ }
+
+ private function filter(
+ ProductDomainObject $product,
+ ?PromoCodeDomainObject $promoCode,
+ ?int $eventOccurrenceId,
+ ?EventDomainObject $event = null,
+ ?OrderPlatformFeePassThroughService $platformFeeService = null,
+ ): void {
+ $eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $eventRepository->shouldReceive('findById')
+ ->with(self::EVENT_ID)
+ ->andReturn($event ?? (new EventDomainObject)->setId(self::EVENT_ID)->setCurrency('EUR'));
+
+ $quantities = Mockery::mock(AvailableProductQuantitiesFetchService::class);
+ $quantities->shouldReceive('getAvailableProductQuantities')
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(productQuantities: $product->getProductPrices()
+ ->map(fn (ProductPriceDomainObject $price) => new AvailableProductQuantitiesDTO(
+ product_id: $product->getId(),
+ price_id: $price->getId(),
+ product_title: 'Ticket',
+ price_label: null,
+ quantity_available: 50,
+ quantity_reserved: 0,
+ initial_quantity_available: null,
+ product_type: ProductType::TICKET->name,
+ ))
+ ->values()));
+
+ $visibility = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class);
+ $visibility->shouldReceive('findWhere')->andReturn(collect());
+
+ $service = new ProductFilterService(
+ taxCalculationService: new TaxAndFeeCalculationService(new TaxAndFeeRollupService),
+ productPriceService: $this->productPriceService(),
+ fetchAvailableProductQuantitiesService: $quantities,
+ platformFeeService: $platformFeeService ?? Mockery::mock(OrderPlatformFeePassThroughService::class),
+ eventRepository: $eventRepository,
+ productOccurrenceVisibilityRepository: $visibility,
+ seatedProductLookup: Mockery::mock(SeatedProductLookupService::class, ['linksForEvent' => collect()]),
+ );
+
+ $service->filterProducts(
+ products: collect([$product]),
+ promoCode: $promoCode,
+ hideSoldOutProducts: false,
+ eventOccurrenceId: $eventOccurrenceId,
+ );
+ }
+
+ private function processOrder(
+ ProductDomainObject $product,
+ ?PromoCodeDomainObject $promoCode,
+ int $quantity,
+ ?int $eventOccurrenceId = null,
+ ): void {
+ $this->capturedOrderItems = [];
+ $event = (new EventDomainObject)->setId(self::EVENT_ID)->setCurrency('EUR');
+
+ $orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $orderRepository->shouldReceive('addOrderItem')
+ ->andReturnUsing(function (array $data) {
+ $this->capturedOrderItems[] = $data;
+
+ return Mockery::mock(OrderItemDomainObject::class);
+ });
+
+ $productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $productRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $productRepository->shouldReceive('findFirstWhere')->andReturn($product);
+
+ $eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $eventRepository->shouldReceive('findById')->andReturn($event);
+
+ $seatedProductLookup = Mockery::mock(SeatedProductLookupService::class);
+ $seatedProductLookup->shouldReceive('priceAdjustmentFor')->andReturn(0);
+
+ $eventSeatMapLookup = Mockery::mock(EventSeatMapLookupService::class);
+ $eventSeatMapLookup->shouldReceive('bandOf')->andReturn(null);
+
+ $service = new OrderItemProcessingService(
+ orderRepository: $orderRepository,
+ productRepository: $productRepository,
+ taxCalculationService: new TaxAndFeeCalculationService(new TaxAndFeeRollupService),
+ productPriceService: $this->productPriceService(),
+ platformFeeService: Mockery::mock(OrderPlatformFeePassThroughService::class),
+ eventRepository: $eventRepository,
+ orderDiscountAllocationService: new OrderDiscountAllocationService,
+ seatedProductLookup: $seatedProductLookup,
+ eventSeatMapLookup: $eventSeatMapLookup,
+ );
+
+ $service->process(
+ order: (new OrderDomainObject)->setId(99),
+ productsOrderDetails: collect([new ProductOrderDetailsDTO(
+ product_id: $product->getId(),
+ quantities: collect([new OrderProductPriceDTO(quantity: $quantity, price_id: 100)]),
+ event_occurrence_id: $eventOccurrenceId,
+ )]),
+ event: $event,
+ promoCode: $promoCode,
+ );
+ }
+
+ private function cloudDefaultEvent(): EventDomainObject
+ {
+ return (new EventDomainObject)
+ ->setId(self::EVENT_ID)
+ ->setCurrency('USD')
+ ->setEventSettings((new EventSettingDomainObject)->setPassPlatformFeeToBuyer(true))
+ ->setOrganizer((new OrganizerDomainObject)->setOrganizerConfiguration(
+ (new OrganizerConfigurationDomainObject)->setApplicationFees([
+ 'percentage' => 1.5,
+ 'fixed' => 0,
+ 'currency' => 'USD',
+ ]),
+ ));
+ }
+
+ private function cloudPlatformFeeService(): OrderPlatformFeePassThroughService
+ {
+ return new OrderPlatformFeePassThroughService(
+ new Repository(['app' => ['saas_mode_enabled' => true]]),
+ Mockery::mock(CurrencyConversionClientInterface::class),
+ );
+ }
+
+ private function productPriceService(): ProductPriceService
+ {
+ $overrides = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class);
+ $overrides->shouldReceive('findFirstWhere')
+ ->andReturnUsing(fn (array $where) => array_key_exists($where['product_price_id'], $this->overridePrices)
+ && $where['event_occurrence_id'] === self::OCCURRENCE_ID
+ ? (new ProductPriceOccurrenceOverrideDomainObject)->setPrice($this->overridePrices[$where['product_price_id']])
+ : null);
+
+ return new ProductPriceService($overrides);
+ }
+
+ private function createPaidProduct(float $price, array $taxesAndFees = []): ProductDomainObject
+ {
+ return (new ProductDomainObject)
+ ->setId(1)
+ ->setEventId(self::EVENT_ID)
+ ->setType(ProductPriceType::PAID->name)
+ ->setProductType(ProductType::TICKET->name)
+ ->setTitle('Ticket')
+ ->setTaxAndFees(collect($taxesAndFees))
+ ->setProductPrices(collect([
+ (new ProductPriceDomainObject)->setId(100)->setPrice($price),
+ ]));
+ }
+
+ private function fee(TaxCalculationType $calculationType, float $rate): TaxAndFeesDomainObject
+ {
+ return (new TaxAndFeesDomainObject)
+ ->setId(1)
+ ->setName('Booking fee')
+ ->setType(TaxType::FEE->name)
+ ->setCalculationType($calculationType->name)
+ ->setRate($rate);
+ }
+
+ private function tax(TaxCalculationType $calculationType, float $rate): TaxAndFeesDomainObject
+ {
+ return (new TaxAndFeesDomainObject)
+ ->setId(2)
+ ->setName('VAT')
+ ->setType(TaxType::TAX->name)
+ ->setCalculationType($calculationType->name)
+ ->setRate($rate);
+ }
+
+ private static function percentagePromo(float $discount): PromoCodeDomainObject
+ {
+ return (new PromoCodeDomainObject)
+ ->setDiscountType(PromoCodeDiscountTypeEnum::PERCENTAGE->name)
+ ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::EACH_PRODUCT->name)
+ ->setDiscount($discount);
+ }
+
+ private static function fixedPromo(float $discount): PromoCodeDomainObject
+ {
+ return (new PromoCodeDomainObject)
+ ->setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name)
+ ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::EACH_PRODUCT->name)
+ ->setDiscount($discount);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Infrastructure/Lock/TransactionLockServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Lock/TransactionLockServiceTest.php
index f2b2465c3d..3816135108 100644
--- a/backend/tests/Unit/Services/Infrastructure/Lock/TransactionLockServiceTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/Lock/TransactionLockServiceTest.php
@@ -23,6 +23,17 @@ public function test_event_lock_uses_the_event_keyspace(): void
(new TransactionLockService($databaseManager))->lockEvent(42);
}
+ public function test_event_lineup_lock_uses_its_own_keyspace_within_int4_range(): void
+ {
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager
+ ->shouldReceive('statement')
+ ->once()
+ ->with('SELECT pg_advisory_xact_lock(?, ?)', [TransactionLockService::LINEUP_LOCK_KEYSPACE, 7]);
+
+ (new TransactionLockService($databaseManager))->lockEventLineup(TransactionLockService::MAX_LOCK_KEY + 7);
+ }
+
public function test_order_lock_hashes_the_short_id_in_the_order_keyspace(): void
{
$databaseManager = Mockery::mock(DatabaseManager::class);
diff --git a/docker/all-in-one/.env.example b/docker/all-in-one/.env.example
index 8136ded63d..728556ed83 100644
--- a/docker/all-in-one/.env.example
+++ b/docker/all-in-one/.env.example
@@ -30,6 +30,8 @@ APP_LICENCE_KEY=
# Optional. Proxies allowed to set X-Forwarded-For. The default `*` trusts the nearest proxy only. Behind more than one
# proxy, list every hop (`cloudflare` adds Cloudflare's ranges), e.g. 127.0.0.1,172.16.0.0/12,cloudflare
APP_TRUSTED_PROXIES=*
+# Optional. API requests a minute per user, shared by the web app and all of that user's API keys
+# APP_API_RATE_LIMIT_PER_MINUTE=180
APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=0
APP_SAAS_STRIPE_APPLICATION_FEE_FIXED=0
APP_EMAIL_LOGO_URL=
@@ -93,9 +95,10 @@ API_DOCS_ENABLED=false
# Only set this when the provider verifies every email address it issues
# SSO_OIDC_TRUST_EMAIL=false
# Turn off email and password sign-in for everyone except super admins
+# Users who have not linked SSO can only get in if the provider vouches for their email; for SSO_OIDC_* that needs
+# SSO_OIDC_TRUST_EMAIL=true. Preview how many users are affected before turning it on:
+# docker compose exec -e SSO_PASSWORD_LOGIN_DISABLED=true all-in-one php /app/backend/artisan sso:status
# SSO_PASSWORD_LOGIN_DISABLED=false
-# Hide the sign-in buttons until launch; add ?sso=1 to the sign-in page URL to see them
-# SSO_BUTTONS_VISIBLE=true
# Let identity providers on private networks or plain http be used, for example an internal Keycloak.
# Never set this on an install where people you don't trust can manage an account's single sign-on.
# SSO_ALLOW_PRIVATE_ISSUERS=false
diff --git a/docker/all-in-one/README.md b/docker/all-in-one/README.md
index 5cb854cc92..ae8ca30b34 100644
--- a/docker/all-in-one/README.md
+++ b/docker/all-in-one/README.md
@@ -76,6 +76,28 @@ Let people sign in with Google, Microsoft or any OpenID Connect provider (Okta,
To make everyone except super admins sign in through single sign-on, set `SSO_PASSWORD_LOGIN_DISABLED=true`. If your provider is ever unavailable, unset it and restart to get password sign-in back.
+Before turning password sign-in off, make sure existing users can get in: they must have linked single sign-on from their profile, or your provider must vouch for their email (Google: Gmail and Workspace accounts; Microsoft: work accounts with the `xms_edov` claim; any other OpenID Connect provider: only with `SSO_OIDC_TRUST_EMAIL=true`). Anyone else is locked out until password sign-in is back on. To see how many users this affects, run this before setting `SSO_PASSWORD_LOGIN_DISABLED=true`:
+
+```bash
+docker compose exec -e SSO_PASSWORD_LOGIN_DISABLED=true all-in-one php /app/backend/artisan sso:status
+```
+
+If someone's identity provider account is compromised or replaced, remove their single sign-on logins and sign them out everywhere:
+
+```bash
+docker compose exec all-in-one php /app/backend/artisan sso:unlink-identity person@example.com
+```
+
+If password sign-in is on and their organization doesn't require single sign-on, they can then set a password with "Forgot password". Otherwise they sign in again through single sign-on, which finds their account only by an email the provider vouches for.
+
+For an account's own single sign-on connection, a domain is normally verified with a DNS TXT record. If you have checked ownership another way, or need to move a domain that another account has verified, mark it verified for the right account. Pass `--account` with the account ID when more than one account has added the domain:
+
+```bash
+docker compose exec all-in-one php /app/backend/artisan sso:verify-domain example.com --account=1
+```
+
+The domain is suspended after seven days without its TXT record, so ask the account to keep the record in place.
+
---
**Production Note:**
diff --git a/docker/all-in-one/docker-compose.yml b/docker/all-in-one/docker-compose.yml
index 5e6a0f875c..6f138296a5 100644
--- a/docker/all-in-one/docker-compose.yml
+++ b/docker/all-in-one/docker-compose.yml
@@ -27,6 +27,7 @@ services:
- APP_LICENCE_KEY=${APP_LICENCE_KEY:-}
- APP_TRUSTED_PROXIES=${APP_TRUSTED_PROXIES:-*}
- APP_SSR_SHARED_SECRET=${APP_SSR_SHARED_SECRET:-}
+ - APP_API_RATE_LIMIT_PER_MINUTE=${APP_API_RATE_LIMIT_PER_MINUTE:-180}
- APP_PUBLIC_ORDER_RATE_LIMIT_PER_MINUTE=${APP_PUBLIC_ORDER_RATE_LIMIT_PER_MINUTE:-60}
- APP_PUBLIC_PROMO_CODE_RATE_LIMIT_PER_MINUTE=${APP_PUBLIC_PROMO_CODE_RATE_LIMIT_PER_MINUTE:-10}
- APP_REGISTER_RATE_LIMIT_PER_HOUR=${APP_REGISTER_RATE_LIMIT_PER_HOUR:-10}
@@ -43,7 +44,6 @@ services:
- SSO_OIDC_LOGO_URL=${SSO_OIDC_LOGO_URL:-}
- SSO_OIDC_TRUST_EMAIL=${SSO_OIDC_TRUST_EMAIL:-false}
- SSO_PASSWORD_LOGIN_DISABLED=${SSO_PASSWORD_LOGIN_DISABLED:-false}
- - SSO_BUTTONS_VISIBLE=${SSO_BUTTONS_VISIBLE:-true}
- SSO_ALLOW_PRIVATE_ISSUERS=${SSO_ALLOW_PRIVATE_ISSUERS:-false}
- APP_SEAT_MAP_MAX_SEATS=${APP_SEAT_MAP_MAX_SEATS:-3000}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
@@ -66,6 +66,7 @@ services:
- GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
- FILESYSTEM_PUBLIC_DISK=${FILESYSTEM_PUBLIC_DISK}
- FILESYSTEM_PRIVATE_DISK=${FILESYSTEM_PRIVATE_DISK}
+ - DB_CONNECTION=pgsql
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-secret}@postgres:5432/${POSTGRES_DB:-hi-events}
- REDIS_HOST=redis
- REDIS_PASSWORD=
diff --git a/e2e/README.md b/e2e/README.md
index 290c661227..d90b7826cd 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -123,6 +123,7 @@ npm test # run everything
npm run test:ui # Playwright UI mode (great for authoring)
npm run test:headed # headed browser
npm run test:smoke # only @smoke-tagged specs
+npx playwright test --config tests '\.unit\.spec\.ts$' # pure-logic unit specs, no stack needed
npm run report # open the last HTML report
npm run typecheck # tsc --noEmit
```
@@ -266,6 +267,9 @@ Lanes:
(`E2E_SAAS_MODE=false`) and a SaaS stack (`E2E_SAAS_MODE=true`), each sharded across two
parallel jobs with their own hermetic stack.
- Adding the **`full-e2e` label** to a PR forces the full lane on that PR.
+- **Every run**, pull requests included, also runs all `*.unit.spec.ts` files in a
+ stack-less `e2e unit specs` job (`npx playwright test --config tests '\.unit\.spec\.ts$'`),
+ so pure-logic specs don't need an `@smoke` tag.
A final `summary` job merges every shard's `results.json` (via `scripts/github-summary.mjs`)
into one report on the run's summary page (and as a PR comment): a pass/fail table per
diff --git a/e2e/api/api-client.ts b/e2e/api/api-client.ts
index 185d48470d..8b2b85df43 100644
--- a/e2e/api/api-client.ts
+++ b/e2e/api/api-client.ts
@@ -623,6 +623,11 @@ export interface UpsertAnnouncementPayload {
cta_url?: string;
}
+export interface CreateConfigurationPayload {
+ name: string;
+ application_fees: { percentage: number; fixed: number; currency: string };
+}
+
export class AdminApiClient {
constructor(private readonly request: APIRequestContext) {}
@@ -670,6 +675,23 @@ export class AdminApiClient {
return unwrap(this.request.get('admin/configurations', { headers: jsonHeaders }));
}
+ createConfiguration(payload: CreateConfigurationPayload): Promise<{ id: number; name: string }> {
+ return unwrap(this.request.post('admin/configurations', { headers: jsonHeaders, data: payload }));
+ }
+
+ deleteConfiguration(configurationId: number): Promise {
+ return check(this.request.delete(`admin/configurations/${configurationId}`, { headers: jsonHeaders }));
+ }
+
+ assignOrganizerConfiguration(organizerId: number, configurationId: number): Promise {
+ return check(
+ this.request.put(`admin/organizers/${organizerId}/configuration`, {
+ headers: jsonHeaders,
+ data: { configuration_id: configurationId },
+ }),
+ );
+ }
+
createAnnouncement(payload: UpsertAnnouncementPayload): Promise<{ id: number }> {
return unwrap(this.request.post('admin/announcements', { headers: jsonHeaders, data: payload }));
}
diff --git a/e2e/pages/lineup.page.ts b/e2e/pages/lineup.page.ts
index b75e688a52..e1f43176c5 100644
--- a/e2e/pages/lineup.page.ts
+++ b/e2e/pages/lineup.page.ts
@@ -36,7 +36,7 @@ export class LineupPage {
async fillProfile(details: { tagline: string; link: string }): Promise {
const drawer = this.drawer();
await drawer.getByLabel(/^Tagline/).fill(details.tagline);
- await drawer.getByLabel('Link 1').fill(details.link);
+ await drawer.getByLabel('Link 1', { exact: true }).fill(details.link);
}
async uploadPhoto(filePath: string): Promise {
@@ -67,7 +67,7 @@ export class LineupPage {
const dialog = this.page.getByRole('dialog');
await this.page.getByTestId('lineup-slot-people').click();
await this.page.getByRole('option', { name: details.person }).click();
- await pickDate(this.page, dialog.getByLabel('Date'), details.date);
+ await pickDate(this.page, dialog.getByRole('button', { name: /^Date/ }), details.date);
await dialog.getByLabel('Starts').fill(details.starts);
await dialog.getByLabel('Ends').fill(details.ends);
await this.page.getByTestId('lineup-slot-save-button').click();
diff --git a/e2e/pages/organizer.page.ts b/e2e/pages/organizer.page.ts
index f2299aeec5..0e60eb40ae 100644
--- a/e2e/pages/organizer.page.ts
+++ b/e2e/pages/organizer.page.ts
@@ -41,8 +41,8 @@ export class OrganizerPage {
await this.page.waitForLoadState('networkidle');
}
- async openCreateOrganizerModal(avatarInitials: string): Promise {
- await this.page.getByRole('button', { name: avatarInitials, exact: true }).click();
+ async openCreateOrganizerModal(): Promise {
+ await this.page.getByTestId('account-menu-button').click();
await this.page.getByRole('menuitem', { name: 'Create Organizer' }).click();
}
diff --git a/e2e/pages/promo-code.page.ts b/e2e/pages/promo-code.page.ts
index cb1beb015f..b62cc68314 100644
--- a/e2e/pages/promo-code.page.ts
+++ b/e2e/pages/promo-code.page.ts
@@ -1,11 +1,12 @@
import type { Page } from '@playwright/test';
-import { pickDateTime } from '../utils/datePicker';
+import { pickDate, pickDateTime } from '../utils/datePicker';
interface CreatePromoCodeOptions {
discountType?: 'Percentage' | 'Fixed amount';
discount?: number;
appliesTo?: 'Entire order' | 'Each product';
expiryDate?: string;
+ expiryDay?: string;
}
export class PromoCodePage {
@@ -39,6 +40,11 @@ export class PromoCodePage {
await pickDateTime(this.page, this.page.getByLabel('Expiry Date'), options.expiryDate);
}
+ if (options.expiryDay) {
+ await this.page.getByTestId('promo-code-advanced-toggle').click();
+ await pickDate(this.page, this.page.getByLabel('Expiry Date'), options.expiryDay);
+ }
+
await this.page.getByTestId('promo-code-submit-button').click();
}
diff --git a/e2e/pages/sso.page.ts b/e2e/pages/sso.page.ts
index c7092a81fd..5ecc3a33d7 100644
--- a/e2e/pages/sso.page.ts
+++ b/e2e/pages/sso.page.ts
@@ -30,7 +30,13 @@ export class SsoPage {
export const providerIsConfigured = async (page: Page): Promise => {
const response = await page.request.get('/api/auth/sso/providers');
- if (!response.ok()) return false;
+ if (!response.ok()) {
+ throw new Error(`Listing the sign-in providers failed with HTTP ${response.status()}`);
+ }
const body = await response.json();
- return body.data.providers.some((provider: { key: string }) => provider.key === 'oidc');
+ const configured = body.data.providers.some((provider: { key: string }) => provider.key === 'oidc');
+ if (!configured && process.env.CI) {
+ throw new Error('No OIDC provider is configured on this stack; set SSO_OIDC_* for the e2e backend');
+ }
+ return configured;
};
diff --git a/e2e/tests/account/api-key-create-modal.spec.ts b/e2e/tests/account/api-key-create-modal.spec.ts
new file mode 100644
index 0000000000..ee59acdcff
--- /dev/null
+++ b/e2e/tests/account/api-key-create-modal.spec.ts
@@ -0,0 +1,34 @@
+import { expect, test } from '../../fixtures';
+import { ApiKeysPage } from '../../pages/api-keys.page';
+
+const REMINDER = "We'll email you a week before.";
+
+test.describe('Create API key modal', () => {
+ test('only promises an expiry reminder for keys that get one', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const apiKeys = new ApiKeysPage(page);
+
+ await apiKeys.goto();
+ await apiKeys.openCreate();
+
+ await apiKeys.fillCreateForm({ name: 'Short lived', expiry: '7 days', password: freshAccount.password });
+ await expect(apiKeys.dialog()).toContainText('Stops working on');
+ await expect(apiKeys.dialog()).not.toContainText(REMINDER);
+
+ await apiKeys.fillCreateForm({ name: 'Short lived', expiry: '30 days', password: freshAccount.password });
+ await expect(apiKeys.dialog()).toContainText(REMINDER);
+ });
+
+ test('the one-time key reveal moves focus to the copy button', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const apiKeys = new ApiKeysPage(page);
+
+ await apiKeys.goto();
+ await apiKeys.openCreate();
+ await apiKeys.fillCreateForm({ name: 'Focus check', password: freshAccount.password });
+ await apiKeys.submitCreate();
+
+ await expect(apiKeys.dialog().getByRole('heading', { name: 'Copy your API key' })).toBeVisible();
+ await expect(page.getByTestId('api-key-copy')).toBeFocused();
+ });
+});
diff --git a/e2e/tests/account/api-reference-landmarks.spec.ts b/e2e/tests/account/api-reference-landmarks.spec.ts
new file mode 100644
index 0000000000..a40fff9c36
--- /dev/null
+++ b/e2e/tests/account/api-reference-landmarks.spec.ts
@@ -0,0 +1,18 @@
+import { expect, test } from '../../fixtures';
+
+test.describe('API reference landmarks', () => {
+ for (const width of [390, 1440]) {
+ test(`at ${width}px the API reference sits inside the one main landmark`, async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto('/settings/api-keys/reference');
+
+ const reference = page.getByTestId('api-reference');
+ await expect(reference.getByRole('heading', { name: /API$/, level: 1 })).toBeVisible({ timeout: 30_000 });
+
+ await expect(page.getByRole('main')).toHaveCount(1);
+ await expect(page.getByRole('main')).toHaveAttribute('id', 'app-manage-main');
+ await expect(reference.getByRole('region', { name: /^API documentation for / })).toBeVisible();
+ });
+ }
+});
diff --git a/e2e/tests/account/legacy-settings-redirects.spec.ts b/e2e/tests/account/legacy-settings-redirects.spec.ts
new file mode 100644
index 0000000000..cb84968834
--- /dev/null
+++ b/e2e/tests/account/legacy-settings-redirects.spec.ts
@@ -0,0 +1,25 @@
+import {expect, test} from '../../fixtures';
+
+const legacyLinks: [string, string][] = [
+ ['/account', '/settings/account'],
+ ['/account/users', '/settings/team'],
+ ['/account/taxes-and-fees', '/settings/taxes-and-fees'],
+ ['/account/danger-zone', '/settings/account#delete-account'],
+ ['/account/settings', '/settings/account'],
+ ['/manage/account', '/settings/account'],
+ ['/manage/profile', '/settings/profile'],
+ ['/manage/profile/security', '/settings/security'],
+ ['/manage/profile/password', '/settings/security'],
+ ['/settings', '/settings/profile'],
+];
+
+test.describe('@smoke legacy settings links', () => {
+ test('redirect on the server, before any JavaScript runs', async ({request}) => {
+ for (const [legacyPath, settingsPath] of legacyLinks) {
+ const response = await request.get(legacyPath, {maxRedirects: 0});
+
+ expect(response.status(), legacyPath).toBe(302);
+ expect(response.headers()['location'], legacyPath).toBe(settingsPath);
+ }
+ });
+});
diff --git a/e2e/tests/account/member-access-states.spec.ts b/e2e/tests/account/member-access-states.spec.ts
new file mode 100644
index 0000000000..527748f151
--- /dev/null
+++ b/e2e/tests/account/member-access-states.spec.ts
@@ -0,0 +1,119 @@
+import type { Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket, createRecurringLiveEvent } from '../../api/factory';
+import { ProductCreatePage } from '../../pages/product-create.page';
+import { invitedMember } from '../../utils/team';
+
+const NO_ACCESS = "You don't have access to this page";
+
+async function statGridGap(page: Page): Promise {
+ const firstCell = page.getByTestId('event-stat-attendees');
+ await expect(firstCell).toBeVisible();
+ return firstCell.evaluate((cell) => {
+ const grid = cell.parentElement as HTMLElement;
+ const cells = Array.from(grid.children);
+ const lastCell = cells[cells.length - 1].getBoundingClientRect();
+ return Math.abs(grid.getBoundingClientRect().right - lastCell.right);
+ });
+}
+
+test.describe('member access states', () => {
+ test('a viewer gets no dead create or select controls', async ({ freshAccount, mailpit, publicApi, browser }) => {
+ const event = await createLiveEventWithFreeTicket(freshAccount.api, freshAccount.organizerId);
+ const recurring = await createRecurringLiveEvent(freshAccount.api, freshAccount.organizerId, { count: 2 });
+ const { page } = await invitedMember(freshAccount, mailpit, publicApi, browser, { role: 'VIEWER' });
+
+ await page.goto(`/manage/event/${event.eventId}/messages`);
+ await expect(page.getByRole('heading', { name: 'No messages to show' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Send your first message' })).toHaveCount(0);
+
+ for (const width of [1440, 800, 390]) {
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto(`/manage/event/${event.eventId}/dashboard`);
+ await expect(page.getByTestId('event-stat-orders')).toBeVisible();
+ await expect(page.getByTestId('event-stat-gross_sales')).toHaveCount(0);
+ expect(await statGridGap(page), `stat grid at ${width}px`).toBeLessThan(2);
+ }
+ await page.setViewportSize({ width: 1440, height: 900 });
+
+ await page.goto('/settings/organizers');
+ const organizerRow = page.getByTestId('settings-organizer-row').first();
+ await expect(organizerRow).toContainText('Open');
+ await expect(organizerRow).not.toContainText('Open settings');
+ await expect(organizerRow).toHaveAttribute('href', `/manage/organizer/${freshAccount.organizerId}/dashboard`);
+
+ await page.goto(`/manage/event/${recurring.eventId}/occurrences`);
+ await expect(page.getByText(/Showing \d+–\d+ of \d+/)).toBeVisible();
+ await expect(page.getByRole('checkbox', { name: 'Select date' })).toHaveCount(0);
+ await expect(page.getByRole('checkbox', { name: 'Select all' })).toHaveCount(0);
+
+ const ownerPage = await freshAccount.newAuthedPage();
+ await ownerPage.goto(`/manage/event/${recurring.eventId}/occurrences`);
+ await expect(ownerPage.getByRole('checkbox', { name: 'Select date' })).toHaveCount(2);
+ await ownerPage.goto('/settings/organizers');
+ await expect(ownerPage.getByTestId('settings-organizer-row').first())
+ .toHaveAttribute('href', `/manage/organizer/${freshAccount.organizerId}/settings`);
+ });
+
+ test('a member without financials gets an access message on reports and webhooks', async ({ freshAccount, mailpit, publicApi, browser }) => {
+ const event = await createLiveEventWithFreeTicket(freshAccount.api, freshAccount.organizerId);
+ const { page } = await invitedMember(freshAccount, mailpit, publicApi, browser, {
+ role: 'ORGANIZER',
+ permissions: { VIEW_FINANCIALS: false },
+ });
+ const base = `/manage/event/${event.eventId}`;
+ const organizerBase = `/manage/organizer/${freshAccount.organizerId}`;
+
+ for (const path of [`${base}/reports`, `${base}/report/product_sales`, `${organizerBase}/report/revenue_summary`]) {
+ await page.goto(path);
+ await expect(page.getByRole('heading', { name: NO_ACCESS }), path).toBeVisible();
+ await expect(page.getByText('Nothing to show yet')).toHaveCount(0);
+ await expect(page.getByText('No data found')).toHaveCount(0);
+ }
+
+ for (const path of [`${base}/webhooks`, `${organizerBase}/webhooks`]) {
+ await page.goto(path);
+ await expect(page.getByRole('heading', { name: NO_ACCESS }), path).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Add Webhook' })).toHaveCount(0);
+ }
+
+ await page.goto(`${organizerBase}/report/check_in_summary`);
+ await expect(page.getByText('Check-in Summary').first()).toBeVisible();
+ await expect(page.getByRole('heading', { name: NO_ACCESS })).toHaveCount(0);
+ await expect(page).not.toHaveURL(/\/auth\/login/);
+
+ const ownerPage = await freshAccount.newAuthedPage();
+ await ownerPage.goto(`${base}/reports`);
+ await expect(ownerPage.getByText('Product Sales').first()).toBeVisible();
+ await expect(ownerPage.getByRole('heading', { name: NO_ACCESS })).toHaveCount(0);
+ });
+
+ test('an event-only member sees why an event is closed to them and no account-level tax control', async ({ freshAccount, mailpit, publicApi, browser }) => {
+ const granted = await createLiveEventWithFreeTicket(freshAccount.api, freshAccount.organizerId);
+ const hidden = await createLiveEventWithFreeTicket(freshAccount.api, freshAccount.organizerId);
+ const { page } = await invitedMember(freshAccount, mailpit, publicApi, browser, {
+ role: 'ORGANIZER',
+ access_scope: 'RESTRICTED',
+ event_ids: [granted.eventId],
+ });
+
+ await page.goto(`/manage/event/${hidden.eventId}/dashboard`);
+ await expect(page.getByRole('heading', { name: "You don't have access to this event" })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Go to your events' })).toHaveAttribute('href', '/manage/events');
+ await expect(page).toHaveURL(new RegExp(`/manage/event/${hidden.eventId}/dashboard`));
+
+ const products = new ProductCreatePage(page);
+ await products.goto(granted.eventId);
+ await products.openCreateModal();
+ await products.openLedgerRow('taxes');
+ await expect(page.getByRole('combobox', { name: 'Taxes and Fees' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Create Tax or Fee' })).toHaveCount(0);
+
+ const ownerPage = await freshAccount.newAuthedPage();
+ const ownerProducts = new ProductCreatePage(ownerPage);
+ await ownerProducts.goto(granted.eventId);
+ await ownerProducts.openCreateModal();
+ await ownerProducts.openLedgerRow('taxes');
+ await expect(ownerPage.getByRole('button', { name: 'Create Tax or Fee' })).toBeVisible();
+ });
+});
diff --git a/e2e/tests/account/pending-deletion-member.spec.ts b/e2e/tests/account/pending-deletion-member.spec.ts
new file mode 100644
index 0000000000..dfbacdb340
--- /dev/null
+++ b/e2e/tests/account/pending-deletion-member.spec.ts
@@ -0,0 +1,31 @@
+import { test, expect } from '../../fixtures';
+import { invitedMember } from '../../utils/team';
+
+test.describe('account pending deletion', () => {
+ test('a restricted member lands on their profile once instead of reloading forever', async ({ freshAccount, mailpit, publicApi, browser }) => {
+ const { page } = await invitedMember(freshAccount, mailpit, publicApi, browser, {
+ role: 'ORGANIZER',
+ access_scope: 'RESTRICTED',
+ organizer_ids: [freshAccount.organizerId],
+ });
+ const account = await freshAccount.api.getAccount();
+ await freshAccount.api.requestAccountDeletion(account.name);
+
+ let loads = 0;
+ page.on('load', () => loads++);
+ await page.goto('/manage/events');
+
+ await expect(page).toHaveURL(/\/settings\/profile/);
+ await expect(page.getByText('This account is scheduled for deletion on')).toBeVisible();
+ await expect(page.getByTestId('pending-deletion-banner-button')).toHaveCount(0);
+ const loadsOnceSettled = loads;
+ await page.waitForTimeout(3_000);
+ expect(loads).toBe(loadsOnceSettled);
+ await expect(page).toHaveURL(/\/settings\/profile/);
+
+ const ownerPage = await freshAccount.newAuthedPage();
+ await ownerPage.goto('/manage/events');
+ await expect(ownerPage).toHaveURL(/\/settings\/account#delete-account$/);
+ await expect(ownerPage.getByTestId('cancel-account-deletion-button')).toBeVisible();
+ });
+});
diff --git a/e2e/tests/account/read-only-access.spec.ts b/e2e/tests/account/read-only-access.spec.ts
index be809c61d8..9120db78d9 100644
--- a/e2e/tests/account/read-only-access.spec.ts
+++ b/e2e/tests/account/read-only-access.spec.ts
@@ -157,7 +157,8 @@ test.describe('read-only and permission spot checks', () => {
await expect(page.getByTestId('lineup-import-button')).toHaveCount(0);
await page.goto(`/manage/organizer/${freshAccount.organizerId}/dashboard`);
- await expect(page).toHaveURL(/\/manage\/events/);
+ await expect(page.getByRole('heading', { name: "You don't have access to this organizer" })).toBeVisible();
+ await expect(page).toHaveURL(new RegExp(`/manage/organizer/${freshAccount.organizerId}/dashboard`));
await page.goto('/account/settings');
await expect(page).toHaveURL(/\/settings\/profile/);
diff --git a/e2e/tests/account/settings-dialog-focus.spec.ts b/e2e/tests/account/settings-dialog-focus.spec.ts
new file mode 100644
index 0000000000..20a18dce65
--- /dev/null
+++ b/e2e/tests/account/settings-dialog-focus.spec.ts
@@ -0,0 +1,131 @@
+import { test, expect } from '../../fixtures';
+import { createRecurringLiveEvent } from '../../api/factory';
+import { ApiKeysPage } from '../../pages/api-keys.page';
+import { TeamPage } from '../../pages/team.page';
+import { openAnonymousPage, TwoFactorPage } from '../../pages/two-factor.page';
+import { nextTotpCode } from '../../utils/totp';
+import { uniqueEmail } from '../../utils/unique';
+
+const MEMBER_PASSWORD = 'MemberPass123!';
+
+test.describe('settings dialogs: focus and names', () => {
+ test('a failed identity confirmation puts focus back on the password field', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ await page.goto('/settings/profile');
+
+ await expect(page.getByRole('textbox', { name: 'First name' })).toHaveValue('E2E');
+ await page.locator('#profile-email').fill(uniqueEmail('refocus'));
+ await page.getByTestId('profile-save-button').click();
+
+ const dialog = page.getByRole('dialog', { name: "Confirm it's you" });
+ const password = dialog.getByLabel('Current password');
+ await password.fill('definitely-wrong-password');
+
+ const confirm = dialog.getByTestId('confirm-identity-submit');
+ await confirm.focus();
+ await page.keyboard.press('Enter');
+
+ await expect(dialog.getByText('The password is incorrect.')).toBeVisible();
+ await expect(password).toBeFocused();
+ });
+
+ test('the create API key modal opens on the name field and its unsaved-key warning names its close button', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const apiKeys = new ApiKeysPage(page);
+
+ await apiKeys.goto();
+ await apiKeys.openCreate();
+ await expect(page.getByTestId('api-key-name')).toBeFocused();
+
+ await apiKeys.fillCreateForm({ name: 'Close label check', password: freshAccount.password });
+ await apiKeys.submitCreate();
+ await page.getByTestId('api-key-done').click();
+
+ const warning = page.getByRole('dialog', { name: /You haven't copied this key/ });
+ await expect(warning).toBeVisible();
+ await expect(warning.getByRole('button', { name: 'Close', exact: true })).toBeVisible();
+ });
+
+ test('notifications name their close button', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ await page.goto('/settings/profile');
+
+ const firstName = page.getByRole('textbox', { name: 'First name' });
+ await expect(firstName).toHaveValue('E2E');
+ await firstName.fill('E2E Renamed');
+ await page.getByTestId('profile-save-button').click();
+
+ const notice = page.getByRole('alert').filter({ hasText: 'Profile updated successfully' });
+ await expect(notice.getByRole('button', { name: 'Close', exact: true })).toBeVisible();
+ });
+
+ test('team row actions say which member they act on', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const team = new TeamPage(page);
+ await team.goto();
+
+ const row = team.userRow(freshAccount.email);
+ await expect(row.getByRole('button', { name: 'Edit E2E Organizer', exact: true })).toBeVisible();
+ await expect(row.getByRole('button', { name: 'More actions for E2E Organizer', exact: true })).toBeVisible();
+ });
+
+ test('the occurrence drawer is named by its date', async ({ freshAccount }) => {
+ const event = await createRecurringLiveEvent(freshAccount.api, freshAccount.organizerId, { count: 2 });
+ const page = await freshAccount.newAuthedPage();
+
+ await page.goto(`/manage/event/${event.eventId}/occurrences`);
+ await page.locator('[class*="dateTimeLink"]').first().click();
+
+ const drawer = page.getByRole('dialog').filter({ has: page.locator('[data-status]') });
+ await expect(drawer).toBeVisible();
+ const name = await drawer.evaluate((element) => {
+ const labelledBy = element.getAttribute('aria-labelledby');
+ return labelledBy ? document.getElementById(labelledBy)?.textContent?.trim() ?? '' : '';
+ });
+ expect(name.length).toBeGreaterThan(0);
+ });
+
+ test('two-factor setup opens on the password field in the security dialog', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const security = new TwoFactorPage(page);
+ await security.gotoSecurity();
+
+ await page.getByTestId('two-factor-enable-button').click();
+ await expect(page.getByTestId('two-factor-setup-password')).toBeFocused();
+ });
+
+ test('the required two-factor setup page opens on the password field', async ({ freshAccount, browser, mailpit, publicApi }) => {
+ const { secret } = await freshAccount.api.beginTwoFactorSetup(freshAccount.password);
+ await freshAccount.api.confirmTwoFactorSetup(await nextTotpCode(secret));
+
+ const ownerPage = await freshAccount.newAuthedPage();
+ const team = new TeamPage(ownerPage);
+ await team.goto();
+ const requireSwitch = ownerPage.getByTestId('account-require-two-factor-switch');
+ await requireSwitch.click({ force: true });
+ await expect(requireSwitch).toBeChecked();
+
+ const memberEmail = uniqueEmail('2fa-focus');
+ await freshAccount.api.inviteUser({ first_name: 'Morgan', last_name: 'Member', email: memberEmail, role: 'ORGANIZER' });
+ const inviteUrl = await mailpit.waitForLink(memberEmail, /accept-invitation/, { subjectContains: 'invited to join' });
+ const acceptResponse = await publicApi.post(`auth/invitation/${inviteUrl.pathname.split('/').pop()}`, {
+ data: {
+ first_name: 'Morgan',
+ last_name: 'Member',
+ password: MEMBER_PASSWORD,
+ password_confirmation: MEMBER_PASSWORD,
+ timezone: 'UTC',
+ },
+ });
+ expect(acceptResponse.ok()).toBeTruthy();
+
+ const memberPage = await openAnonymousPage(browser);
+ await new TwoFactorPage(memberPage).signInWithPassword(memberEmail, MEMBER_PASSWORD);
+ await expect(memberPage).toHaveURL(/\/auth\/two-factor-setup/);
+ await expect(memberPage.getByTestId('two-factor-setup-password')).toBeFocused();
+
+ await memberPage.reload();
+ await expect(memberPage.getByTestId('two-factor-setup-password')).toBeFocused();
+ await memberPage.context().close();
+ });
+});
diff --git a/e2e/tests/account/settings-load-errors.spec.ts b/e2e/tests/account/settings-load-errors.spec.ts
new file mode 100644
index 0000000000..2256938d1a
--- /dev/null
+++ b/e2e/tests/account/settings-load-errors.spec.ts
@@ -0,0 +1,56 @@
+import type { Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+
+async function failGetRequests(page: Page, url: RegExp): Promise<() => void> {
+ let failing = true;
+ await page.route(url, (route) => (failing && route.request().method() === 'GET'
+ ? route.fulfill({ status: 500, json: { message: 'Server Error' } })
+ : route.continue()));
+ return () => {
+ failing = false;
+ };
+}
+
+test.describe('settings load errors', () => {
+ test('taxes and fees offers a retry instead of an empty state when loading fails', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const recover = await failGetRequests(page, /\/api\/accounts\/\d+\/taxes-and-fees(\?.*)?$/);
+
+ await page.goto('/settings/taxes-and-fees');
+
+ await expect(page.getByText("Couldn't load your taxes and fees")).toBeVisible();
+ await expect(page.getByText('No taxes or fees yet')).toHaveCount(0);
+ await expect(page.getByRole('button', { name: 'Add tax or fee' })).toHaveCount(0);
+
+ recover();
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await expect(page.getByText('No taxes or fees yet')).toBeVisible();
+ });
+
+ test('organizers offers a retry instead of an empty list when loading fails', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const recover = await failGetRequests(page, /\/api\/organizers(\?.*)?$/);
+
+ await page.goto('/settings/organizers');
+
+ await expect(page.getByText("Couldn't load your organizers")).toBeVisible();
+ await expect(page.getByText('No organizers yet.')).toHaveCount(0);
+
+ recover();
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await expect(page.getByTestId('settings-organizer-row')).toHaveCount(1);
+ });
+
+ test('team offers a retry instead of endless skeletons when loading fails', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const recover = await failGetRequests(page, /\/api\/users(\?.*)?$/);
+
+ await page.goto('/settings/team');
+
+ await expect(page.getByText("Couldn't load your team")).toBeVisible();
+
+ recover();
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await expect(page.getByTestId('team-member-row').filter({ hasText: freshAccount.email })).toBeVisible();
+ });
+});
diff --git a/e2e/tests/account/sign-in-methods-load-error.spec.ts b/e2e/tests/account/sign-in-methods-load-error.spec.ts
new file mode 100644
index 0000000000..de4bbf9404
--- /dev/null
+++ b/e2e/tests/account/sign-in-methods-load-error.spec.ts
@@ -0,0 +1,26 @@
+import { test, expect } from '../../fixtures';
+
+test.describe('sign-in methods load errors', () => {
+ test('offers a retry instead of Connect buttons when connected identities fail to load', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const providersResponse = await page.request.get('/api/auth/sso/providers');
+ expect(providersResponse.ok()).toBeTruthy();
+ const providerKey: string | undefined = (await providersResponse.json()).data.providers[0]?.key;
+ test.skip(!providerKey, 'No sign-in provider is configured on this stack');
+
+ let failing = true;
+ await page.route(/\/api\/users\/me\/sso-identities(\?.*)?$/, (route) => (failing && route.request().method() === 'GET'
+ ? route.fulfill({ status: 500, json: { message: 'Server Error' } })
+ : route.continue()));
+
+ await page.goto('/settings/security');
+
+ await expect(page.getByTestId('sso-identities-retry')).toBeVisible({ timeout: 20_000 });
+ await expect(page.getByTestId(`sso-connect-${providerKey}`)).toHaveCount(0);
+
+ failing = false;
+ await page.getByTestId('sso-identities-retry').click();
+ await expect(page.getByTestId(`sso-connect-${providerKey}`)).toBeVisible();
+ await expect(page.getByTestId('sso-identities-retry')).toHaveCount(0);
+ });
+});
diff --git a/e2e/tests/account/team-access.spec.ts b/e2e/tests/account/team-access.spec.ts
index 333a131675..953f589627 100644
--- a/e2e/tests/account/team-access.spec.ts
+++ b/e2e/tests/account/team-access.spec.ts
@@ -34,7 +34,8 @@ test.describe('team access', () => {
await expect(page.getByText(hiddenTitle)).toHaveCount(0);
await page.goto(`/manage/event/${hidden.eventId}/dashboard`);
- await expect(page).toHaveURL(/\/manage\/events/);
+ await expect(page.getByRole('heading', { name: "You don't have access to this event" })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Go to your events' })).toHaveAttribute('href', '/manage/events');
await expect(page).not.toHaveURL(/\/auth\/login/);
});
@@ -96,7 +97,8 @@ test.describe('team access', () => {
await expect(page.getByText(otherTitle)).toHaveCount(0);
await page.goto(`/manage/organizer/${otherOrganizer.id}/dashboard`);
- await expect(page).not.toHaveURL(new RegExp(`/manage/organizer/${otherOrganizer.id}`));
+ await expect(page.getByRole('heading', { name: "You don't have access to this organizer" })).toBeVisible();
+ await expect(page.getByText('Other Organizer')).toHaveCount(0);
});
test('turning off financials for a member hides order amounts from them', async ({ freshAccount, mailpit, publicApi, browser }) => {
diff --git a/e2e/tests/account/team-edit-member.spec.ts b/e2e/tests/account/team-edit-member.spec.ts
new file mode 100644
index 0000000000..142eaddadf
--- /dev/null
+++ b/e2e/tests/account/team-edit-member.spec.ts
@@ -0,0 +1,34 @@
+import { test, expect } from '../../fixtures';
+import { TeamPage } from '../../pages/team.page';
+import { uniqueEmail } from '../../utils/unique';
+
+test.describe('edit team member', () => {
+ test('a member without a last name can be made a viewer', async ({ freshAccount }) => {
+ const email = uniqueEmail('nolast');
+ await freshAccount.api.inviteUser({ first_name: 'Solo', email, role: 'ORGANIZER' });
+
+ const page = await freshAccount.newAuthedPage();
+ const team = new TeamPage(page);
+ await team.goto();
+ for (const label of ['Role', 'Access', 'Two-factor', 'Last active']) {
+ await expect(team.userRow(email)).toContainText(label);
+ }
+ await team.openEditUserModal(email);
+
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByRole('radiogroup', { name: 'Role' })).toBeVisible();
+ await expect(dialog.getByRole('radiogroup', { name: 'Access' })).toBeVisible();
+ await expect(dialog.getByRole('switch', { name: 'Can see financials' }))
+ .toHaveAccessibleDescription('Revenue, order totals, reports, payouts and payment settings.');
+
+ await team.selectRole('Viewer');
+ const [response] = await Promise.all([
+ page.waitForResponse((res) => res.request().method() === 'PUT' && /\/api\/users\/\d+$/.test(res.url())),
+ team.submitEditUser(),
+ ]);
+
+ expect(response.status()).toBe(200);
+ await expect(page.getByRole('heading', { name: 'Edit member' })).toHaveCount(0);
+ await expect(team.userRow(email)).toContainText('Viewer');
+ });
+});
diff --git a/e2e/tests/account/team-invite.spec.ts b/e2e/tests/account/team-invite.spec.ts
index f7c9aee95e..bea80ce328 100644
--- a/e2e/tests/account/team-invite.spec.ts
+++ b/e2e/tests/account/team-invite.spec.ts
@@ -1,4 +1,5 @@
import { test, expect } from '../../fixtures';
+import { bootstrapAccount } from '../../fixtures/account.fixture';
import { TeamPage } from '../../pages/team.page';
import { uniqueEmail } from '../../utils/unique';
@@ -73,4 +74,49 @@ test.describe('team invites', () => {
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page.getByText('Please check your email and password and try again')).toBeVisible();
});
+
+ test('someone who already has an account signs in from the invitation and joins the team', async ({ freshAccount, page, mailpit, publicApi }) => {
+ const existing = await bootstrapAccount(publicApi, mailpit);
+ const { name: accountName } = await freshAccount.api.getAccount();
+ await freshAccount.api.inviteUser({ first_name: 'Jordan', last_name: 'Existing', email: existing.email, role: 'ORGANIZER' });
+ const inviteUrl = await mailpit.waitForLink(existing.email, /accept-invitation/, { subjectContains: 'invited to join' });
+
+ await page.goto(inviteUrl.pathname);
+ await expect(page.getByRole('heading', { name: `Join ${accountName}` })).toBeVisible();
+ await expect(page.getByText('You already have an account.')).toBeVisible();
+ await page.getByRole('button', { name: 'Sign in with password' }).click();
+
+ await expect(page).toHaveURL(/\/auth\/login/);
+ await expect(page.getByLabel(/^Email/)).toHaveValue(existing.email);
+ await page.getByLabel(/^Password/).fill(existing.password);
+ await page.getByRole('button', { name: 'Sign in', exact: true }).click();
+
+ await expect(page).toHaveURL(new RegExp(inviteUrl.pathname));
+ await page.getByTestId('invitation-join-button').click();
+
+ await expect(page).toHaveURL(new RegExp(`/manage/organizer/${freshAccount.organizerId}`));
+ await expect(page.getByRole('heading', { level: 1, name: /Dashboard/ })).toBeVisible();
+
+ const ownerPage = await freshAccount.newAuthedPage();
+ const team = new TeamPage(ownerPage);
+ await team.goto();
+ await expect(team.userRow(existing.email)).toBeVisible();
+ await expect(team.userRow(existing.email).getByText(/^Invited$/)).toHaveCount(0);
+ });
+
+ test('opening an invitation while signed in as someone else asks you to sign out first', async ({ freshAccount, mailpit, publicApi }) => {
+ const existing = await bootstrapAccount(publicApi, mailpit);
+ await freshAccount.api.inviteUser({ first_name: 'Riley', last_name: 'Existing', email: existing.email, role: 'ORGANIZER' });
+ const inviteUrl = await mailpit.waitForLink(existing.email, /accept-invitation/, { subjectContains: 'invited to join' });
+
+ const ownerPage = await freshAccount.newAuthedPage();
+ await ownerPage.goto(inviteUrl.pathname);
+ await expect(ownerPage.getByText(`You're signed in as ${freshAccount.email}`)).toBeVisible();
+ await expect(ownerPage.getByTestId('invitation-join-button')).toHaveCount(0);
+
+ await ownerPage.getByTestId('invitation-sign-out-button').click();
+
+ await expect(ownerPage.getByText('You already have an account.')).toBeVisible();
+ await expect(ownerPage.getByRole('button', { name: 'Sign in with password' })).toBeVisible();
+ });
});
diff --git a/e2e/tests/auth/account-chooser.spec.ts b/e2e/tests/auth/account-chooser.spec.ts
new file mode 100644
index 0000000000..4860d6af4a
--- /dev/null
+++ b/e2e/tests/auth/account-chooser.spec.ts
@@ -0,0 +1,30 @@
+import { test, expect } from '../../fixtures';
+import { TwoFactorPage } from '../../pages/two-factor.page';
+import { uniqueEmail } from '../../utils/unique';
+import { acceptWithPassword, joinAnotherAccount, latestInvitationPath, MEMBER_PASSWORD } from './team-member.shared';
+
+test.describe('account chooser', () => {
+ test('a problem signing in to the chosen account is shown on the chooser', async ({ freshAccount, browser, mailpit, publicApi, playwright }) => {
+ const memberEmail = uniqueEmail('chooser');
+ await freshAccount.api.inviteUser({ first_name: 'Kim', last_name: 'Member', email: memberEmail, role: 'ORGANIZER' });
+ await acceptWithPassword(publicApi, await latestInvitationPath(mailpit, memberEmail, 1));
+ await joinAnotherAccount(playwright, publicApi, mailpit, memberEmail);
+
+ const context = await browser.newContext({ ignoreHTTPSErrors: true });
+ const page = await context.newPage();
+ await new TwoFactorPage(page).signInWithPassword(memberEmail, MEMBER_PASSWORD);
+ await expect(page.getByRole('heading', { name: 'Choose an account' })).toBeVisible();
+
+ await page.route('**/api/auth/login', (route) => route.fulfill({
+ status: 429,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Too Many Attempts.' }),
+ }));
+ await page.getByTestId('account-chooser-option').first().click();
+
+ await expect(page.getByRole('alert').filter({ hasText: 'Too many attempts. Please wait a minute and try again.' })).toBeVisible();
+ await expect(page.getByTestId('account-chooser-option')).toHaveCount(2);
+ await expect(page.getByTestId('account-chooser-option').first()).toBeEnabled();
+ await context.close();
+ });
+});
diff --git a/e2e/tests/auth/auth-error-redirect.unit.spec.ts b/e2e/tests/auth/auth-error-redirect.unit.spec.ts
new file mode 100644
index 0000000000..6ac5770ad0
--- /dev/null
+++ b/e2e/tests/auth/auth-error-redirect.unit.spec.ts
@@ -0,0 +1,83 @@
+import { expect, test } from '@playwright/test';
+import { resolveAuthErrorAction } from '../../../frontend/src/api/authErrorRedirect';
+
+const LOGIN = { type: 'login' };
+const NONE = { type: 'none' };
+
+test.describe('api client auth error handling', () => {
+ test('an expired session on any manage page sends the user to sign in', () => {
+ for (const path of [
+ '/manage/organizer/1/widget',
+ '/manage/organizer/1/events',
+ '/manage/organizer/1/events/archived',
+ '/manage/events',
+ '/manage/events/upcoming',
+ '/manage/event/1/dashboard',
+ '/manage/event/1/check-in',
+ '/manage/event/1/widget',
+ '/manage/event/1/ticket-designer/print',
+ '/settings/team',
+ '/admin/accounts',
+ '/welcome',
+ ]) {
+ expect(resolveAuthErrorAction(401, undefined, path), path).toEqual(LOGIN);
+ expect(resolveAuthErrorAction(401, 'SSO_SESSION_EXPIRED', path), path).toEqual(LOGIN);
+ expect(resolveAuthErrorAction(403, undefined, path), path).toEqual(LOGIN);
+ expect(resolveAuthErrorAction(403, 'SSO_REQUIRED', path), path).toEqual(LOGIN);
+ }
+ });
+
+ test('public pages never redirect to sign in', () => {
+ for (const path of [
+ '/widget/1',
+ '/widget/organizer/1',
+ '/checkout/1/abc/details',
+ '/event/1/my-event',
+ '/events/1/my-organizer',
+ '/order/1/abc/print',
+ '/product/1/abc',
+ '/check-in/abc',
+ '/box-office/abc',
+ '/my-tickets/abc',
+ '/emails/unsubscribe/abc',
+ '/auth/login',
+ '/auth/register',
+ '/auth/accept-invitation/abc',
+ '/sso/callback',
+ ]) {
+ expect(resolveAuthErrorAction(401, undefined, path), path).toEqual(NONE);
+ expect(resolveAuthErrorAction(403, undefined, path), path).toEqual(NONE);
+ }
+ });
+
+ test('access errors stay on the page everywhere', () => {
+ for (const code of ['FEATURE_UNAVAILABLE', 'ACCESS_RESTRICTED', 'PERMISSION_REQUIRED', 'READ_ONLY_ROLE', 'IMPERSONATION_NOT_ALLOWED']) {
+ for (const path of ['/manage/event/1/dashboard', '/manage/organizer/1/widget', '/settings/team', '/widget/1']) {
+ expect(resolveAuthErrorAction(403, code, path), `${code} ${path}`).toEqual(NONE);
+ }
+ }
+ });
+
+ test('other statuses are left to the caller', () => {
+ for (const status of [400, 404, 409, 422, 429, 500]) {
+ expect(resolveAuthErrorAction(status, undefined, '/manage/event/1/dashboard')).toEqual(NONE);
+ }
+ });
+
+ test('a required two-factor setup redirects once', () => {
+ expect(resolveAuthErrorAction(403, 'TWO_FACTOR_SETUP_REQUIRED', '/manage/events'))
+ .toEqual({ type: 'redirect', to: '/auth/two-factor-setup' });
+ expect(resolveAuthErrorAction(403, 'TWO_FACTOR_SETUP_REQUIRED', '/auth/two-factor-setup')).toEqual(NONE);
+ });
+
+ test('an account pending deletion redirects to the delete section, but never away from settings', () => {
+ for (const path of ['/manage/events', '/manage/event/1/dashboard', '/manage/organizer/1/widget', '/welcome']) {
+ expect(resolveAuthErrorAction(403, 'ACCOUNT_PENDING_DELETION', path), path)
+ .toEqual({ type: 'redirect', to: '/settings/account#delete-account' });
+ }
+
+ for (const path of ['/settings/account', '/settings/profile', '/settings/security', '/settings/team']) {
+ expect(resolveAuthErrorAction(403, 'ACCOUNT_PENDING_DELETION', path), path).toEqual(NONE);
+ }
+ });
+});
diff --git a/e2e/tests/auth/enterprise-sso-access-settings.spec.ts b/e2e/tests/auth/enterprise-sso-access-settings.spec.ts
new file mode 100644
index 0000000000..e2c5ce8e01
--- /dev/null
+++ b/e2e/tests/auth/enterprise-sso-access-settings.spec.ts
@@ -0,0 +1,73 @@
+import { test, expect } from '../../fixtures';
+import type { FreshAccount } from '../../fixtures';
+import { EnterpriseSsoSettings, MOCK_OIDC_URL, mockOidcIsReachable } from '../../pages/enterprise-sso.page';
+import { enableFeatureFlag, markSsoConnectionTested } from '../../utils/db';
+
+const uniqueSuffix = (): string => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
+
+const connectedAccount = async (account: FreshAccount): Promise => {
+ const { id } = await account.api.getAccount();
+ enableFeatureFlag(id, 'enterprise_sso');
+ await account.api.createSsoConnection(id, {
+ type: 'OIDC',
+ issuer: `${MOCK_OIDC_URL}/access-${uniqueSuffix()}`,
+ client_id: 'hievents-e2e',
+ client_secret: 'e2e-secret',
+ });
+ markSsoConnectionTested(id);
+ return id;
+};
+
+test.describe('single sign-on access settings', () => {
+ test.beforeEach(async ({ page }) => {
+ test.skip(!(await mockOidcIsReachable(page)), `No mock identity provider at ${MOCK_OIDC_URL}`);
+ });
+
+ test('changing the session length with the keyboard keeps focus in the drawer', async ({ freshAccount }) => {
+ await connectedAccount(freshAccount);
+ const page = await freshAccount.newAuthedPage();
+ const settings = new EnterpriseSsoSettings(page);
+ await settings.open();
+
+ const sessionLength = settings.drawer().getByRole('combobox', { name: 'Sign people out after' });
+ await expect(sessionLength).toHaveValue('12 hours');
+ await sessionLength.focus();
+ await page.keyboard.press('ArrowDown');
+ await page.getByRole('option', { name: '1 day' }).waitFor();
+ await page.keyboard.press('ArrowDown');
+ await page.keyboard.press('Enter');
+
+ await expect(page.getByText('Single sign-on settings updated')).toBeVisible();
+ await expect(sessionLength).toHaveValue('1 day');
+ await expect(sessionLength).toBeFocused();
+ expect(await page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]'))).toBe(true);
+
+ const jit = settings.drawer().getByTestId('sso-jit-switch');
+ await jit.focus();
+ await page.keyboard.press('Space');
+ await expect(jit).toBeChecked();
+ await expect(jit).toBeFocused();
+ });
+
+ test('a session length that fails to save goes back to the saved value', async ({ freshAccount }) => {
+ await connectedAccount(freshAccount);
+ const page = await freshAccount.newAuthedPage();
+ const settings = new EnterpriseSsoSettings(page);
+ await settings.open();
+
+ await page.route(/\/accounts\/\d+\/sso\/connection$/, route => route.request().method() === 'PUT'
+ ? route.fulfill({
+ status: 422,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Rejected', errors: { session_ttl_minutes: ['Choose a shorter session.'] } }),
+ })
+ : route.fallback());
+
+ const sessionLength = settings.drawer().getByRole('combobox', { name: 'Sign people out after' });
+ await sessionLength.click();
+ await page.getByRole('option', { name: '7 days' }).click();
+
+ await expect(page.getByText('Choose a shorter session.')).toBeVisible();
+ await expect(sessionLength).toHaveValue('12 hours');
+ });
+});
diff --git a/e2e/tests/auth/enterprise-sso-member-owning-another-account.spec.ts b/e2e/tests/auth/enterprise-sso-member-owning-another-account.spec.ts
new file mode 100644
index 0000000000..1787c1eb66
--- /dev/null
+++ b/e2e/tests/auth/enterprise-sso-member-owning-another-account.spec.ts
@@ -0,0 +1,94 @@
+import { expect, test } from '../../fixtures';
+import {
+ EnterpriseSsoSettings,
+ MOCK_OIDC_URL,
+ mockOidcIsReachable,
+ signInAtMockProvider,
+ SsoLoginPage,
+} from '../../pages/enterprise-sso.page';
+import { confirmEmailWithCode, login, registerAccount } from '../../api/api-client';
+import { enableFeatureFlag, markSsoConnectionTested, markSsoDomainVerified } from '../../utils/db';
+import { IS_SAAS_MODE } from '../../utils/env';
+import { nextTotpCode } from '../../utils/totp';
+import { apiAs, latestInvitationPath, MEMBER_PASSWORD } from './team-member.shared';
+
+const uniqueSuffix = (): string => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
+
+test.describe('enterprise single sign-on for a teammate who owns another account', () => {
+ test.beforeEach(async ({ page }) => {
+ test.skip(!(await mockOidcIsReachable(page)), `No mock identity provider at ${MOCK_OIDC_URL}`);
+ });
+
+ test('requiring SSO keeps them signed in, shows them as Password, and SSO tells them to connect it first', async ({ freshAccount, browser, mailpit, publicApi, playwright }) => {
+ const { id: accountId } = await freshAccount.api.getAccount();
+ enableFeatureFlag(accountId, 'enterprise_sso');
+ const issuerId = `owner-elsewhere-${uniqueSuffix()}`;
+ const domain = `${issuerId}.test`;
+ const memberEmail = `kim@${domain}`;
+
+ await freshAccount.api.createSsoConnection(accountId, {
+ type: 'OIDC',
+ issuer: `${MOCK_OIDC_URL}/${issuerId}`,
+ client_id: 'hievents-e2e',
+ client_secret: 'e2e-secret',
+ });
+ markSsoConnectionTested(accountId);
+ await freshAccount.api.setSsoConnectionStatus(accountId, 'ACTIVE');
+ await freshAccount.api.addSsoDomain(accountId, domain);
+ markSsoDomainVerified(accountId, domain);
+
+ await registerAccount(publicApi, {
+ first_name: 'Kim',
+ last_name: 'Member',
+ email: memberEmail,
+ password: MEMBER_PASSWORD,
+ password_confirmation: MEMBER_PASSWORD,
+ timezone: 'UTC',
+ currency_code: 'USD',
+ locale: 'en',
+ });
+ const ownAccount = await login(publicApi, memberEmail, MEMBER_PASSWORD);
+ if (IS_SAAS_MODE && !ownAccount.user.is_email_verified) {
+ await confirmEmailWithCode(publicApi, ownAccount.token, ownAccount.user.id, await mailpit.waitForVerificationCode(memberEmail));
+ }
+ await freshAccount.api.inviteUser({ first_name: 'Kim', last_name: 'Member', email: memberEmail, role: 'ORGANIZER' });
+ const invitationPath = await latestInvitationPath(mailpit, memberEmail, 1);
+ const ownAccountApi = await apiAs(playwright, ownAccount.token);
+ expect((await ownAccountApi.post(`auth/invitation/${invitationPath.split('/').pop()}/join`)).ok()).toBeTruthy();
+ await ownAccountApi.dispose();
+
+ const acmeLogin = await publicApi.post('auth/login', { data: { email: memberEmail, password: MEMBER_PASSWORD, account_id: accountId } });
+ expect(acmeLogin.ok()).toBeTruthy();
+ const memberApi = await apiAs(playwright, (await acmeLogin.json()).token);
+
+ const { secret } = await freshAccount.api.beginTwoFactorSetup(freshAccount.password);
+ await freshAccount.api.confirmTwoFactorSetup(await nextTotpCode(secret));
+
+ const page = await freshAccount.newAuthedPage();
+ const settings = new EnterpriseSsoSettings(page);
+ await settings.open();
+ await settings.toggle('sso-enforce-switch');
+ const preview = page.getByTestId('sso-enforcement-preview');
+ await expect(preview.getByRole('listitem').filter({ hasText: memberEmail }))
+ .toContainText('Owns another Hi.Events account, so keeps signing in with a password');
+ await page.getByTestId('sso-enforce-confirm').click();
+ await expect(page.getByText('Single sign-on is now required')).toBeVisible();
+
+ expect((await memberApi.get('users/me')).status()).toBe(200);
+ await memberApi.dispose();
+
+ await page.goto('/settings/team');
+ const memberRow = page.getByTestId('team-member-row').filter({ hasText: memberEmail });
+ await expect(memberRow).toContainText('Password');
+ await expect(memberRow).not.toContainText('Exempt');
+
+ const context = await browser.newContext({ ignoreHTTPSErrors: true });
+ const signIn = await context.newPage();
+ await new SsoLoginPage(signIn).signInWithSso(memberEmail);
+ await signInAtMockProvider(signIn, issuerId, 'kim', { email: memberEmail, email_verified: true });
+ const error = signIn.getByTestId('sso-error');
+ await expect(error.getByRole('heading', { name: `You already have an account with ${memberEmail}` })).toBeVisible();
+ await expect(error).toContainText('Sign in with your password, then connect');
+ await context.close();
+ });
+});
diff --git a/e2e/tests/auth/enterprise-sso-setup-form.spec.ts b/e2e/tests/auth/enterprise-sso-setup-form.spec.ts
new file mode 100644
index 0000000000..c337420a82
--- /dev/null
+++ b/e2e/tests/auth/enterprise-sso-setup-form.spec.ts
@@ -0,0 +1,94 @@
+import { test, expect } from '../../fixtures';
+import type { FreshAccount } from '../../fixtures';
+import { EnterpriseSsoSettings, MOCK_OIDC_URL, mockOidcIsReachable } from '../../pages/enterprise-sso.page';
+import { enableFeatureFlag, markSsoConnectionTested, markSsoDomainVerified } from '../../utils/db';
+
+const uniqueSuffix = (): string => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
+
+const prepareAccount = async (account: FreshAccount): Promise => {
+ const { id } = await account.api.getAccount();
+ enableFeatureFlag(id, 'enterprise_sso');
+ return id;
+};
+
+const createConnection = async (account: FreshAccount, accountId: number, issuerId: string): Promise => {
+ await account.api.createSsoConnection(accountId, {
+ type: 'OIDC',
+ issuer: `${MOCK_OIDC_URL}/${issuerId}`,
+ client_id: 'hievents-e2e',
+ client_secret: 'e2e-secret',
+ });
+};
+
+test.describe('enterprise single sign-on setup form', () => {
+ test('providers are offered as a radio choice and an empty save lists every missing field at once', async ({ freshAccount }) => {
+ await prepareAccount(freshAccount);
+ const page = await freshAccount.newAuthedPage();
+ const settings = new EnterpriseSsoSettings(page);
+ await settings.open();
+
+ const okta = settings.drawer().getByRole('radio', { name: /Okta/ });
+ await okta.click();
+ await expect(okta).toBeChecked();
+ await expect(settings.drawer().getByRole('radio', { name: /Other OIDC provider/ })).not.toBeChecked();
+
+ await page.getByTestId('sso-connection-save').click();
+ await expect(settings.drawer().getByText('Enter the issuer URL, starting with https://.')).toBeVisible();
+ await expect(settings.drawer().getByText('Enter the client ID.')).toBeVisible();
+ await expect(settings.drawer().getByText('Enter the client secret.')).toBeVisible();
+ });
+
+ test('pointing a saved connection at another issuer asks for the client secret again', async ({ freshAccount, page: probe }) => {
+ test.skip(!(await mockOidcIsReachable(probe)), `No mock identity provider at ${MOCK_OIDC_URL}`);
+ const accountId = await prepareAccount(freshAccount);
+ const otherIssuerId = `moved-${uniqueSuffix()}`;
+ await createConnection(freshAccount, accountId, `saved-${uniqueSuffix()}`);
+
+ const page = await freshAccount.newAuthedPage();
+ const settings = new EnterpriseSsoSettings(page);
+ await settings.open();
+ await settings.drawer().getByRole('button', { name: 'Edit details' }).click();
+ await expect(settings.drawer().getByText('Saved and hidden. Leave blank to keep the current secret.')).toBeVisible();
+
+ const issuer = settings.drawer().getByLabel('Issuer URL');
+ await issuer.fill(`${MOCK_OIDC_URL}/${otherIssuerId}`);
+ await issuer.press('Tab');
+ await expect(settings.drawer().getByText('Found your sign-in settings')).toBeVisible();
+ await expect(settings.drawer().getByText('You changed the provider details, so enter the client secret again.')).toBeVisible();
+ await page.getByTestId('sso-connection-save').click();
+ await expect(settings.drawer().getByText('Enter the client secret again when you change the identity provider details.')).toBeVisible();
+
+ await settings.drawer().getByLabel('Client secret').fill('moved-secret');
+ await page.getByTestId('sso-connection-save').click();
+ await expect(page.getByText('Connection updated')).toBeVisible();
+ await expect(settings.drawer()).toContainText(`${MOCK_OIDC_URL}/${otherIssuerId}`);
+ });
+
+ test('each member in the enforcement preview has an exempt switch named after them', async ({ freshAccount, page: probe }) => {
+ test.skip(!(await mockOidcIsReachable(probe)), `No mock identity provider at ${MOCK_OIDC_URL}`);
+ const accountId = await prepareAccount(freshAccount);
+ const issuerId = `named-${uniqueSuffix()}`;
+ const domain = `${issuerId}.test`;
+ await createConnection(freshAccount, accountId, issuerId);
+ markSsoConnectionTested(accountId);
+ await freshAccount.api.setSsoConnectionStatus(accountId, 'ACTIVE');
+ await freshAccount.api.addSsoDomain(accountId, domain);
+ markSsoDomainVerified(accountId, domain);
+ await freshAccount.api.inviteUser({ first_name: 'Grace', last_name: 'Hopper', email: `grace@${domain}`, role: 'ORGANIZER' });
+ await freshAccount.api.inviteUser({ first_name: 'Alan', last_name: 'Turing', email: `alan@${domain}`, role: 'ORGANIZER' });
+
+ const page = await freshAccount.newAuthedPage();
+ const settings = new EnterpriseSsoSettings(page);
+ await settings.open();
+ await settings.toggle('sso-enforce-switch');
+
+ const preview = page.getByTestId('sso-enforcement-preview');
+ const grace = preview.getByRole('switch', { name: 'Exempt Grace Hopper' });
+ await expect(grace).toBeVisible();
+ await expect(preview.getByRole('switch', { name: 'Exempt Alan Turing' })).toBeVisible();
+
+ await grace.locator('xpath=ancestor::label[1]').click();
+ await expect(grace).toBeChecked();
+ await expect(preview).toContainText('1 member will need to sign in with your identity provider.');
+ });
+});
diff --git a/e2e/tests/auth/enterprise-sso.spec.ts b/e2e/tests/auth/enterprise-sso.spec.ts
index 844a63d2a2..678540af69 100644
--- a/e2e/tests/auth/enterprise-sso.spec.ts
+++ b/e2e/tests/auth/enterprise-sso.spec.ts
@@ -12,12 +12,12 @@ import { enableFeatureFlag, markSsoConnectionTested, markSsoDomainVerified } fro
import { nextTotpCode } from '../../utils/totp';
import { uniqueEmail } from '../../utils/unique';
import { openAuthedPage } from '../../fixtures/auth.fixture';
-import { login } from '../../api/api-client';
+import { ApiClient, login } from '../../api/api-client';
+import { TeamPage } from '../../pages/team.page';
+import { acceptWithPassword, apiAs, joinAnotherAccount, latestInvitationPath, MEMBER_PASSWORD } from './team-member.shared';
const uniqueSuffix = (): string => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
-const MEMBER_PASSWORD = 'MemberPass123!';
-
const prepareAccount = async (account: FreshAccount): Promise => {
const { id } = await account.api.getAccount();
enableFeatureFlag(id, 'enterprise_sso');
@@ -273,4 +273,144 @@ test.describe('enterprise single sign-on', () => {
await mailpit.waitForMessage(freshAccount.email, { subjectContains: 'signed in without single sign-on' });
await context.close();
});
+
+ test('the owner connects the identity provider from security settings and then signs in through it', async ({ freshAccount, browser, playwright }) => {
+ const accountId = await prepareAccount(freshAccount);
+ const issuerId = `link-${uniqueSuffix()}`;
+ const domain = `${issuerId}.test`;
+ const accountName = `Acme ${issuerId}`;
+ const workEmail = `it-lead@${domain}`;
+ const ownerApi = await apiAs(playwright, freshAccount.token);
+ expect((await ownerApi.put(`accounts/${accountId}`, { data: { name: accountName, timezone: 'UTC', currency_code: 'USD' } })).ok()).toBeTruthy();
+ await ownerApi.dispose();
+ const { provider_key: providerKey } = await freshAccount.api.createSsoConnection(accountId, {
+ type: 'OIDC',
+ issuer: `${MOCK_OIDC_URL}/${issuerId}`,
+ client_id: 'hievents-e2e',
+ client_secret: 'e2e-secret',
+ });
+ markSsoConnectionTested(accountId);
+ await freshAccount.api.setSsoConnectionStatus(accountId, 'ACTIVE');
+ await freshAccount.api.addSsoDomain(accountId, domain);
+ markSsoDomainVerified(accountId, domain);
+ const { secret } = await freshAccount.api.beginTwoFactorSetup(freshAccount.password);
+ await freshAccount.api.confirmTwoFactorSetup(await nextTotpCode(secret));
+ await freshAccount.api.updateSsoEnforcement(accountId, true);
+
+ const page = await freshAccount.newAuthedPage();
+ await page.goto('/settings/security');
+ await expect(page.getByText(`Not connected. ${accountName} uses this for single sign-on.`)).toBeVisible();
+ await page.getByTestId(`sso-connect-${providerKey}`).click();
+ await page.getByRole('dialog').getByLabel(/Current password/).fill(freshAccount.password);
+ await page.getByTestId('confirm-identity-submit').click();
+ await signInAtMockProvider(page, issuerId, 'it-lead', { email: workEmail, email_verified: true });
+
+ await expect(page).toHaveURL(/\/settings\/security/);
+ await expect(page.getByTestId(`sso-identity-${providerKey}`)).toContainText(workEmail);
+
+ const context = await browser.newContext({ ignoreHTTPSErrors: true });
+ const signIn = await context.newPage();
+ await new SsoLoginPage(signIn).signInWithSso(workEmail);
+ await signInAtMockProvider(signIn, issuerId, 'it-lead', { email: workEmail, email_verified: true });
+ const twoFactor = new TwoFactorPage(signIn);
+ await twoFactor.expectChallenge();
+ await twoFactor.enterLoginCode(await nextTotpCode(secret));
+ await expect(signIn).toHaveURL(/\/manage\//);
+ await context.close();
+ });
+
+ test('someone invited while single sign-on is required joins through the identity provider instead of setting a password', async ({ freshAccount, browser, mailpit }) => {
+ const accountId = await prepareAccount(freshAccount);
+ const issuerId = `invite-${uniqueSuffix()}`;
+ const domain = `${issuerId}.test`;
+ const inviteeEmail = `new.hire@${domain}`;
+ await activeConnection(freshAccount, accountId, issuerId, domain);
+ const { secret } = await freshAccount.api.beginTwoFactorSetup(freshAccount.password);
+ await freshAccount.api.confirmTwoFactorSetup(await nextTotpCode(secret));
+ await freshAccount.api.updateSsoEnforcement(accountId, true);
+ await freshAccount.api.inviteUser({ first_name: 'New', last_name: 'Hire', email: inviteeEmail, role: 'ORGANIZER' });
+ const invitationPath = await latestInvitationPath(mailpit, inviteeEmail, 1);
+
+ const context = await browser.newContext({ ignoreHTTPSErrors: true });
+ const page = await context.newPage();
+ await page.goto(invitationPath);
+ await expect(page.getByText('Your organization requires single sign-on')).toBeVisible();
+ await expect(page.getByLabel(/^Password/)).toHaveCount(0);
+ await page.getByTestId('invitation-sso-button').click();
+
+ await expect(page.getByLabel('Work email')).toHaveValue(inviteeEmail);
+ await page.getByTestId('sso-discovery-submit').click();
+ await signInAtMockProvider(page, issuerId, 'new-hire', { email: inviteeEmail, email_verified: true });
+ await expect(page).toHaveURL(new RegExp(`/manage/organizer/${freshAccount.organizerId}`));
+ await context.close();
+
+ const team = new TeamPage(await freshAccount.newAuthedPage());
+ await team.goto();
+ await expect(team.userRow(inviteeEmail)).toBeVisible();
+ await expect(team.userRow(inviteeEmail).getByText(/^Invited$/)).toHaveCount(0);
+ });
+
+ test('a member of two accounts who picks the one that requires single sign-on is sent to SSO', async ({ freshAccount, browser, mailpit, publicApi, playwright }) => {
+ const accountId = await prepareAccount(freshAccount);
+ const issuerId = `pick-${uniqueSuffix()}`;
+ const domain = `${issuerId}.test`;
+ const memberEmail = `kim@${domain}`;
+ const accountName = `Acme ${issuerId}`;
+ const ownerApi = await apiAs(playwright, freshAccount.token);
+ expect((await ownerApi.put(`accounts/${accountId}`, { data: { name: accountName, timezone: 'UTC', currency_code: 'USD' } })).ok()).toBeTruthy();
+ await ownerApi.dispose();
+ await activeConnection(freshAccount, accountId, issuerId, domain);
+ await freshAccount.api.inviteUser({ first_name: 'Kim', last_name: 'Member', email: memberEmail, role: 'ORGANIZER' });
+ await acceptWithPassword(publicApi, await latestInvitationPath(mailpit, memberEmail, 1));
+ await joinAnotherAccount(playwright, publicApi, mailpit, memberEmail);
+
+ const { secret } = await freshAccount.api.beginTwoFactorSetup(freshAccount.password);
+ await freshAccount.api.confirmTwoFactorSetup(await nextTotpCode(secret));
+ await freshAccount.api.updateSsoEnforcement(accountId, true);
+
+ const context = await browser.newContext({ ignoreHTTPSErrors: true });
+ const page = await context.newPage();
+ await new TwoFactorPage(page).signInWithPassword(memberEmail, MEMBER_PASSWORD);
+ await expect(page.getByRole('heading', { name: 'Choose an account' })).toBeVisible();
+ await page.getByTestId('account-chooser-option').filter({ hasText: accountName }).click();
+
+ const required = page.getByRole('alert').filter({ hasText: 'Your organization requires single sign-on' });
+ await expect(required).toBeVisible();
+ await required.getByRole('button', { name: 'Sign in with SSO' }).click();
+ await expect(page.getByLabel('Work email')).toHaveValue(memberEmail);
+ await context.close();
+ });
+
+ test('a member with two-factor who must use single sign-on is sent to SSO before being asked for a code', async ({ freshAccount, browser, mailpit, publicApi, playwright }) => {
+ const accountId = await prepareAccount(freshAccount);
+ const issuerId = `mfa-${uniqueSuffix()}`;
+ const domain = `${issuerId}.test`;
+ const memberEmail = `kim@${domain}`;
+ await activeConnection(freshAccount, accountId, issuerId, domain);
+ await freshAccount.api.inviteUser({ first_name: 'Kim', last_name: 'Member', email: memberEmail, role: 'ORGANIZER' });
+ await acceptWithPassword(publicApi, await latestInvitationPath(mailpit, memberEmail, 1));
+
+ const memberApi = await apiAs(playwright, (await login(publicApi, memberEmail, MEMBER_PASSWORD)).token);
+ const member = new ApiClient(memberApi);
+ const { secret: memberSecret } = await member.beginTwoFactorSetup(MEMBER_PASSWORD);
+ await member.confirmTwoFactorSetup(await nextTotpCode(memberSecret));
+ await memberApi.dispose();
+
+ const { secret } = await freshAccount.api.beginTwoFactorSetup(freshAccount.password);
+ await freshAccount.api.confirmTwoFactorSetup(await nextTotpCode(secret));
+ await freshAccount.api.updateSsoEnforcement(accountId, true);
+
+ const context = await browser.newContext({ ignoreHTTPSErrors: true });
+ const page = await context.newPage();
+ const twoFactor = new TwoFactorPage(page);
+ await twoFactor.signInWithPassword(memberEmail, MEMBER_PASSWORD);
+
+ const required = page.getByRole('alert').filter({ hasText: 'Your organization requires single sign-on' });
+ await expect(required).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Two-factor authentication' })).toHaveCount(0);
+ await expect(page.getByText('Something went wrong')).toHaveCount(0);
+ await required.getByRole('button', { name: 'Sign in with SSO' }).click();
+ await expect(page.getByLabel('Work email')).toHaveValue(memberEmail);
+ await context.close();
+ });
});
diff --git a/e2e/tests/auth/password.spec.ts b/e2e/tests/auth/password.spec.ts
index 9ee970bbba..937c85c804 100644
--- a/e2e/tests/auth/password.spec.ts
+++ b/e2e/tests/auth/password.spec.ts
@@ -17,7 +17,7 @@ test.describe('password management', () => {
await dialog.getByRole('button', { name: 'Change password' }).click();
await expect(page.getByText('Password changed')).toBeVisible();
- await page.getByRole('button', { name: 'EO', exact: true }).click();
+ await page.getByTestId('account-menu-button').click();
await page.getByRole('menuitem', { name: 'Logout' }).click();
await expect(page).toHaveURL(/\/auth\/login/);
await page.waitForLoadState('networkidle');
diff --git a/e2e/tests/auth/session-expiry-redirect.spec.ts b/e2e/tests/auth/session-expiry-redirect.spec.ts
new file mode 100644
index 0000000000..c0fa9524ad
--- /dev/null
+++ b/e2e/tests/auth/session-expiry-redirect.spec.ts
@@ -0,0 +1,16 @@
+import { test, expect } from '../../fixtures';
+import { openAuthedPage } from '../../fixtures/auth.fixture';
+
+test.describe('expired session', () => {
+ for (const path of ['/manage/organizer/1/widget', '/manage/organizer/1/events/archived', '/manage/events/upcoming']) {
+ test(`${path} sends the user to sign in`, async ({ browser }) => {
+ const page = await openAuthedPage(browser, 'expired-session-token');
+
+ await page.goto(path);
+
+ await expect(page).toHaveURL(/\/auth\/login/);
+ await expect(page.getByRole('button', { name: /sign in|log in/i }).first()).toBeVisible();
+ await page.context().close();
+ });
+ }
+});
diff --git a/e2e/tests/auth/team-member.shared.ts b/e2e/tests/auth/team-member.shared.ts
new file mode 100644
index 0000000000..9143888d94
--- /dev/null
+++ b/e2e/tests/auth/team-member.shared.ts
@@ -0,0 +1,47 @@
+import { expect, type APIRequest, type APIRequestContext } from '@playwright/test';
+import { ApiClient, login } from '../../api/api-client';
+import { bootstrapAccount } from '../../fixtures/account.fixture';
+import { API_BASE_URL } from '../../utils/env';
+import type { MailpitClient } from '../../utils/mailpit';
+
+export const MEMBER_PASSWORD = 'MemberPass123!';
+
+export const latestInvitationPath = async (mailpit: MailpitClient, email: string, invitationCount: number): Promise => {
+ const invitations = async () => (await mailpit.search(email)).filter((message) => message.Subject.includes('invited to join'));
+ await expect.poll(async () => (await invitations()).length, { timeout: 15_000 }).toBeGreaterThanOrEqual(invitationCount);
+ const [latest] = await invitations();
+ const link = (await mailpit.getLinks(latest.ID)).find((href) => href.includes('accept-invitation'));
+ return new URL(link as string).pathname;
+};
+
+export const acceptWithPassword = async (publicApi: APIRequestContext, invitationPath: string): Promise => {
+ const accept = await publicApi.post(`auth/invitation/${invitationPath.split('/').pop()}`, {
+ data: { first_name: 'Kim', last_name: 'Member', password: MEMBER_PASSWORD, password_confirmation: MEMBER_PASSWORD, timezone: 'UTC' },
+ });
+ expect(accept.ok()).toBeTruthy();
+};
+
+export const apiAs = (playwright: { request: APIRequest }, token: string): Promise =>
+ playwright.request.newContext({
+ baseURL: API_BASE_URL,
+ ignoreHTTPSErrors: true,
+ extraHTTPHeaders: { Accept: 'application/json', Authorization: `Bearer ${token}` },
+ });
+
+export const joinAnotherAccount = async (
+ playwright: { request: APIRequest },
+ publicApi: APIRequestContext,
+ mailpit: MailpitClient,
+ memberEmail: string,
+): Promise => {
+ const otherOwner = await bootstrapAccount(publicApi, mailpit);
+ const otherOwnerApi = await apiAs(playwright, otherOwner.token);
+ const invitationCount = (await mailpit.search(memberEmail)).filter((message) => message.Subject.includes('invited to join')).length;
+ await new ApiClient(otherOwnerApi).inviteUser({ first_name: 'Kim', last_name: 'Member', email: memberEmail, role: 'ORGANIZER' });
+ const invitationPath = await latestInvitationPath(mailpit, memberEmail, invitationCount + 1);
+
+ const memberApi = await apiAs(playwright, (await login(publicApi, memberEmail, MEMBER_PASSWORD)).token);
+ expect((await memberApi.post(`auth/invitation/${invitationPath.split('/').pop()}/join`)).ok()).toBeTruthy();
+ await otherOwnerApi.dispose();
+ await memberApi.dispose();
+};
diff --git a/e2e/tests/checkout/promo-code-inclusive-platform-fee.spec.ts b/e2e/tests/checkout/promo-code-inclusive-platform-fee.spec.ts
new file mode 100644
index 0000000000..60e294003c
--- /dev/null
+++ b/e2e/tests/checkout/promo-code-inclusive-platform-fee.spec.ts
@@ -0,0 +1,47 @@
+import { test as base, expect } from '../../fixtures';
+import { CheckoutPage } from '../../pages/checkout.page';
+import { createLiveEventWithPaidTicket } from '../../api/factory';
+import { IS_SAAS_MODE } from '../../utils/env';
+import { uniqueCode, uniqueName } from '../../utils/unique';
+
+const test = base.extend<{ feeOrganizerId: number }>({
+ feeOrganizerId: async ({ api, adminApi }, use) => {
+ const organizer = await api.createOrganizer(uniqueName('E2E Platform Fee Org'));
+ const configuration = await adminApi.createConfiguration({
+ name: uniqueName('E2E 1.5% Platform Fee'),
+ application_fees: { percentage: 1.5, fixed: 0, currency: 'USD' },
+ });
+ await adminApi.assignOrganizerConfiguration(organizer.id, configuration.id);
+
+ await use(organizer.id);
+
+ const systemDefault = (await adminApi.listConfigurations()).find((config) => config.is_system_default);
+ if (systemDefault) {
+ await adminApi.assignOrganizerConfiguration(organizer.id, systemDefault.id);
+ await adminApi.deleteConfiguration(configuration.id);
+ }
+ },
+});
+
+test.describe('promo code with a platform fee passed to the buyer', () => {
+ test.skip(!IS_SAAS_MODE, 'The platform fee is only charged in SaaS mode.');
+
+ test('the inclusive struck price is the fee-inclusive price the buyer saw before the promo', async ({ page, api, feeOrganizerId }) => {
+ const event = await createLiveEventWithPaidTicket(api, feeOrganizerId, 20);
+ await api.updateEventSettings(event.eventId, { price_display_mode: 'INCLUSIVE', pass_platform_fee_to_buyer: true });
+ const code = uniqueCode();
+ await api.createPromoCode(event.eventId, { code, discount_type: 'PERCENTAGE', discount: 10 });
+
+ const checkout = new CheckoutPage(page);
+ await checkout.gotoPublicEvent(event.eventId, event.slug);
+ const productRow = page.locator('.hi-product-row').filter({ hasText: event.productTitle });
+ const shownPrice = productRow.locator('.hi-price-amount').first();
+ await expect(shownPrice).toHaveText('$20.30');
+ await expect(productRow.locator('.hi-price-strike')).toHaveCount(0);
+
+ await checkout.applyPromoCode(code);
+
+ await expect(shownPrice).toHaveText('$18.27');
+ await expect(productRow.locator('.hi-price-strike')).toHaveText('$20.30');
+ });
+});
diff --git a/e2e/tests/checkout/promo-code-inclusive-tax.spec.ts b/e2e/tests/checkout/promo-code-inclusive-tax.spec.ts
new file mode 100644
index 0000000000..e161026ed0
--- /dev/null
+++ b/e2e/tests/checkout/promo-code-inclusive-tax.spec.ts
@@ -0,0 +1,40 @@
+import { test, expect } from '../../fixtures';
+import { CheckoutPage } from '../../pages/checkout.page';
+import { createLiveEventWithProduct } from '../../api/factory';
+import { uniqueCode, uniqueName } from '../../utils/unique';
+
+test.describe('promo code with tax-inclusive prices', () => {
+ test('the struck price includes VAT and the cart total matches the order total', async ({ page, api, account }) => {
+ const { id: accountId } = await api.getAccount();
+ const vat = await api.createTaxOrFee(accountId, {
+ name: uniqueName('VAT'),
+ description: 'Inclusive VAT',
+ calculation_type: 'PERCENTAGE',
+ type: 'TAX',
+ rate: 23,
+ is_active: true,
+ is_default: false,
+ });
+ const event = await createLiveEventWithProduct(api, { organizerId: account.organizerId, price: 9.99, taxIds: [vat.id] });
+ await api.updateEventSettings(event.eventId, { price_display_mode: 'INCLUSIVE', pass_platform_fee_to_buyer: false });
+ const code = uniqueCode();
+ await api.createPromoCode(event.eventId, { code, discount_type: 'PERCENTAGE', discount: 15 });
+
+ const checkout = new CheckoutPage(page);
+ await page.setViewportSize({ width: 1280, height: 400 });
+ await checkout.gotoPublicEvent(event.eventId, event.slug);
+ await checkout.applyPromoCode(code);
+
+ const productRow = page.locator('.hi-product-row').filter({ hasText: event.productTitle });
+ await expect(productRow.locator('.hi-price-amount').first()).toHaveText('$10.44');
+ await expect(productRow.locator('.hi-price-strike')).toHaveText('$12.29');
+
+ await checkout.setQuantityForProduct(event.productTitle, 3);
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await expect(page.getByTestId('floating-checkout-button')).toContainText('$31.33');
+
+ await checkout.continueToCheckout();
+ const totalRow = page.locator('[class*="totalsRow"]').filter({ has: page.getByText('Total', { exact: true }) });
+ await expect(totalRow).toContainText('$31.33');
+ });
+});
diff --git a/e2e/tests/checkout/promo-code-keyboard.spec.ts b/e2e/tests/checkout/promo-code-keyboard.spec.ts
new file mode 100644
index 0000000000..388a643caf
--- /dev/null
+++ b/e2e/tests/checkout/promo-code-keyboard.spec.ts
@@ -0,0 +1,43 @@
+import { test, expect } from '../../fixtures';
+import { CheckoutPage } from '../../pages/checkout.page';
+import { createLiveEventWithPaidTicket } from '../../api/factory';
+import { uniqueCode } from '../../utils/unique';
+
+test.describe('promo code keyboard access', () => {
+ test('a keyboard-only buyer can open, apply and dismiss the promo code field', async ({ page, api, account }) => {
+ const event = await createLiveEventWithPaidTicket(api, account.organizerId);
+ const code = uniqueCode();
+ await api.createPromoCode(event.eventId, { code, discount_type: 'PERCENTAGE', discount: 10 });
+
+ const checkout = new CheckoutPage(page);
+ await checkout.gotoPublicEvent(event.eventId, event.slug);
+ await checkout.setQuantityForProduct(event.productTitle, 1);
+ const productPrice = page.locator('.hi-product-row').filter({ hasText: event.productTitle }).locator('.hi-price-amount').first();
+ const fullPrice = (await productPrice.textContent()) ?? '';
+
+ const trigger = page.getByRole('button', { name: 'Have a promo code?' });
+ const continueButton = page.getByTestId('checkout-continue-button');
+ await continueButton.focus();
+ await page.keyboard.press('Tab');
+ await expect(trigger).toBeFocused();
+
+ await page.keyboard.press('Enter');
+ const promoInput = page.getByRole('textbox', { name: 'Promo code' });
+ await expect(promoInput).toBeFocused();
+
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Tab');
+ await expect(page.getByRole('button', { name: 'close' })).toBeFocused();
+ await page.keyboard.press('Enter');
+ await expect(promoInput).toHaveCount(0);
+ await expect(trigger).toBeFocused();
+
+ await page.keyboard.press('Enter');
+ await expect(promoInput).toBeFocused();
+ await page.keyboard.type(code);
+ await page.keyboard.press('Enter');
+
+ await expect(page.locator('.hi-promo-code-applied')).toContainText(code);
+ await expect(productPrice).not.toHaveText(fullPrice);
+ });
+});
diff --git a/e2e/tests/licensing/licence-states.spec.ts b/e2e/tests/licensing/licence-states.spec.ts
index 0d1d18065d..e4360afea3 100644
--- a/e2e/tests/licensing/licence-states.spec.ts
+++ b/e2e/tests/licensing/licence-states.spec.ts
@@ -241,9 +241,27 @@ test.describe('licence notices', () => {
const modal = await openLicenceModal(page);
await expect(modal).toContainText('Setup for box office is locked');
+ await expect(modal).toContainText('New box office sign-ins are refused, so open box office sessions end within 24 hours');
await expect(modal.getByRole('link', { name: 'Get a licence' })).toHaveAttribute('href', /hi\.events\/licensing/);
});
+ test('a lapsed licence says what keeps working and what stops', async ({ freshAccount }) => {
+ await createFixtureSeatMap(freshAccount.api, freshAccount.organizerId, 'theatre', 'Main hall');
+ const eventId = await createEventWithBoxOffice(freshAccount.api, freshAccount.organizerId);
+ const page = await freshAccount.newAuthedPage();
+ await simulateLicence(page, 'LAPSED');
+
+ await page.goto(`/manage/event/${eventId}/box-office`);
+ await expect(licenceBadge(page)).toHaveAttribute('data-tone', 'danger');
+
+ const modal = await openLicenceModal(page);
+ await expect(modal).toContainText('Setup for reserved seating and box office is locked until you renew');
+ await expect(modal).toContainText('Online ticket sales for events with reserved seating keep working');
+ await expect(modal).toContainText('New box office sign-ins are refused, so open box office sessions end within 24 hours');
+ await expect(modal).not.toContainText('scheduled dates');
+ await expect(modal.getByRole('link', { name: 'Renew licence' })).toHaveAttribute('href', /hi\.events\/licensing/);
+ });
+
test('deleting the last seat map template clears the unlicensed notice', async ({ freshAccount }) => {
const seatMapId = await createFixtureSeatMap(freshAccount.api, freshAccount.organizerId, 'theatre', 'Old hall');
const page = await freshAccount.newAuthedPage();
diff --git a/e2e/tests/lineup/lineup-schedule-days.unit.spec.ts b/e2e/tests/lineup/lineup-schedule-days.unit.spec.ts
new file mode 100644
index 0000000000..e6f94d228c
--- /dev/null
+++ b/e2e/tests/lineup/lineup-schedule-days.unit.spec.ts
@@ -0,0 +1,84 @@
+import { expect, test } from '@playwright/test';
+import {
+ adjacentOccurrenceDay,
+ localScheduleDayKey,
+ occurrenceDayKeys,
+ scheduleCoverage,
+} from '../../../frontend/src/components/routes/event/Lineup/ScheduleBuilder/scheduleDays';
+import type { EventLineup, LineupEntry, ScheduleItem } from '../../../frontend/src/api/lineup.client';
+
+const entry = (id: number): LineupEntry => ({
+ id,
+ billing_tier: 'STANDARD',
+ role_label: null,
+ profile: { id, name: `Person ${id}`, tagline: null, bio: null, links: [], images: [] },
+});
+
+const slot = (id: number, startsAt: string, entryIds: number[]): ScheduleItem => ({
+ id,
+ title: null,
+ description: null,
+ starts_at: startsAt,
+ ends_at: startsAt,
+ event_space_id: null,
+ lineup_entry_ids: entryIds,
+});
+
+test.describe('timetable days for recurring events', () => {
+ test('a slot saved after midnight belongs to the evening before', () => {
+ expect(localScheduleDayKey('2030-06-15 01:00:00')).toBe('2030-06-14');
+ expect(localScheduleDayKey('2030-06-15 04:59:00')).toBe('2030-06-14');
+ expect(localScheduleDayKey('2030-06-15 05:00:00')).toBe('2030-06-15');
+ expect(localScheduleDayKey('2030-06-14 22:00:00')).toBe('2030-06-14');
+ expect(localScheduleDayKey('2030-03-01 02:00:00')).toBe('2030-02-28');
+ });
+
+ test('occurrence days cover every timetable day an occurrence spans, once each', () => {
+ expect(occurrenceDayKeys([
+ { start_date: '2030-06-21 19:00:00', end_date: '2030-06-21 23:00:00' },
+ { start_date: '2030-06-14 19:00:00', end_date: '2030-06-15 02:00:00' },
+ { start_date: '2030-06-14 21:00:00', end_date: null },
+ { start_date: '2030-06-28 10:00:00', end_date: '2030-06-30 18:00:00' },
+ ], 'UTC')).toEqual(['2030-06-14', '2030-06-21', '2030-06-28', '2030-06-29', '2030-06-30']);
+ });
+
+ test('occurrence days follow the event time zone', () => {
+ expect(occurrenceDayKeys([{ start_date: '2030-06-14 23:30:00', end_date: '2030-06-15 01:00:00' }], 'America/New_York'))
+ .toEqual(['2030-06-14']);
+ });
+
+ test('previous and next step between occurrence days', () => {
+ const days = ['2030-06-14', '2030-06-21', '2030-06-28'];
+
+ expect(adjacentOccurrenceDay(days, '2030-06-14', 1)).toBe('2030-06-21');
+ expect(adjacentOccurrenceDay(days, '2030-06-17', 1)).toBe('2030-06-21');
+ expect(adjacentOccurrenceDay(days, '2030-06-17', -1)).toBe('2030-06-14');
+ expect(adjacentOccurrenceDay(days, '2030-06-28', 1)).toBeUndefined();
+ expect(adjacentOccurrenceDay(days, '2030-06-14', -1)).toBeUndefined();
+ });
+
+ test('people booked only on days with no occurrence are not counted as scheduled', () => {
+ const lineup: EventLineup = {
+ entries: [entry(1), entry(2), entry(3), entry(4)],
+ spaces: [],
+ schedule_items: [
+ slot(10, '2030-06-14 20:00:00', [1]),
+ slot(11, '2030-06-19 20:00:00', [2]),
+ slot(12, '2030-06-19 21:00:00', [3]),
+ slot(13, '2030-06-21 20:00:00', [3]),
+ ],
+ };
+
+ expect(scheduleCoverage(lineup, 'UTC', new Set(['2030-06-14', '2030-06-21']))).toEqual({ scheduled: 2, offDateOnly: 1 });
+ });
+
+ test('every slot counts when the event has no occurrence days to check against', () => {
+ const lineup: EventLineup = {
+ entries: [entry(1), entry(2)],
+ spaces: [],
+ schedule_items: [slot(10, '2030-06-19 20:00:00', [1])],
+ };
+
+ expect(scheduleCoverage(lineup, 'UTC')).toEqual({ scheduled: 1, offDateOnly: 0 });
+ });
+});
diff --git a/e2e/tests/lineup/lineup-schedule.unit.spec.ts b/e2e/tests/lineup/lineup-schedule.unit.spec.ts
index ecc3cc068b..186237a909 100644
--- a/e2e/tests/lineup/lineup-schedule.unit.spec.ts
+++ b/e2e/tests/lineup/lineup-schedule.unit.spec.ts
@@ -56,6 +56,29 @@ test.describe('lineup schedule grouping', () => {
expect(firstWeek.entries.map(candidate => candidate.id)).toEqual([1, 3]);
});
+ test('a hidden schedule still limits each date to the people booked on it', () => {
+ const lineup: EventLineup = {
+ entries: [
+ { ...entry(1), schedule_days: ['2030-06-14', '2030-06-28'] },
+ { ...entry(2), schedule_days: ['2030-06-21'] },
+ { ...entry(3), schedule_days: [] },
+ entry(4),
+ ],
+ spaces: [],
+ schedule_items: [],
+ };
+
+ const firstWeek = lineupForOccurrence(lineup, { start_date: '2030-06-14 19:00:00', end_date: '2030-06-15 02:00:00' }, 'UTC');
+ expect(firstWeek.entries.map(candidate => candidate.id)).toEqual([1, 3, 4]);
+ expect(firstWeek.schedule_items).toEqual([]);
+
+ const secondWeek = lineupForOccurrence(lineup, { start_date: '2030-06-22 00:30:00', end_date: null }, 'America/New_York');
+ expect(secondWeek.entries.map(candidate => candidate.id)).toEqual([2, 3, 4]);
+
+ const weekend = lineupForOccurrence(lineup, { start_date: '2030-06-27 18:00:00', end_date: '2030-06-29 23:00:00' }, 'UTC');
+ expect(weekend.entries.map(candidate => candidate.id)).toEqual([1, 3, 4]);
+ });
+
test('clock changes use the local wall clock for the 05:00 boundary', () => {
expect(scheduleDayKey('2030-10-27 04:30:00', 'Europe/Dublin')).toBe('2030-10-26');
expect(scheduleDayKey('2030-10-27 05:00:00', 'Europe/Dublin')).toBe('2030-10-27');
diff --git a/e2e/tests/lineup/public-lineup-mixed-photos.spec.ts b/e2e/tests/lineup/public-lineup-mixed-photos.spec.ts
new file mode 100644
index 0000000000..82721ca8fb
--- /dev/null
+++ b/e2e/tests/lineup/public-lineup-mixed-photos.spec.ts
@@ -0,0 +1,67 @@
+import fs from 'node:fs';
+import type { Locator, Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket } from '../../api/factory';
+import { PublicEventPage } from '../../pages/public-event.page';
+import { uniqueName } from '../../utils/unique';
+
+const PHOTO = fs.readFileSync(new URL('../../fixtures/assets/gallery-photo.png', import.meta.url));
+
+const uploadProfilePhoto = async (page: Page, profileId: number): Promise => {
+ const response = await page.request.post('/api/images', {
+ headers: { Accept: 'application/json' },
+ multipart: { image: { name: 'photo.png', mimeType: 'image/png', buffer: PHOTO }, image_type: 'PROFILE_IMAGE', entity_id: String(profileId) },
+ });
+ expect(response.ok()).toBeTruthy();
+};
+
+const box = async (locator: Locator) => (await locator.boundingBox())!;
+
+const cardParts = (card: Locator) => ({
+ media: card.locator(':scope > span').first(),
+ body: card.locator(':scope > span').last(),
+});
+
+test.describe('public lineup cards with and without photos', () => {
+ test('a photo-less headliner or featured act lines up with its neighbour that has a photo', async ({ page, authedPage, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const names = {
+ headlinerPhoto: uniqueName('Photo Headliner'),
+ headlinerPlain: uniqueName('Plain Headliner'),
+ featuredPhoto: uniqueName('Photo Featured'),
+ featuredPlain: uniqueName('Plain Featured'),
+ };
+ for (const [key, name] of Object.entries(names)) {
+ const profile = await api.createLineupProfile(account.organizerId, { name, tagline: 'Live set' });
+ if (key.endsWith('Photo')) {
+ await uploadProfilePhoto(authedPage, profile.id);
+ }
+ await api.addLineupEntry(event.eventId, { profile_id: profile.id, billing_tier: key.startsWith('headliner') ? 'HEADLINER' : 'FEATURED' });
+ }
+
+ const card = (name: string) => page.getByTestId('event-lineup-card').filter({ hasText: name });
+
+ for (const viewport of [{ width: 768, height: 1024 }, { width: 1440, height: 900 }]) {
+ await page.setViewportSize(viewport);
+ await new PublicEventPage(page).goto(event.eventId, event.slug);
+
+ const photoHeadliner = await box(card(names.headlinerPhoto));
+ const plainHeadliner = await box(card(names.headlinerPlain));
+ expect(Math.abs(plainHeadliner.y - photoHeadliner.y)).toBeLessThan(1);
+ expect(Math.abs(plainHeadliner.height - photoHeadliner.height)).toBeLessThan(1);
+ const plainHeadlinerBody = await box(cardParts(card(names.headlinerPlain)).body);
+ expect(plainHeadliner.y + plainHeadliner.height - (plainHeadlinerBody.y + plainHeadlinerBody.height)).toBeLessThan(2);
+ const photoMedia = await box(cardParts(card(names.headlinerPhoto)).media);
+ const photoBody = await box(cardParts(card(names.headlinerPhoto)).body);
+ expect(photoBody.y + photoBody.height).toBeLessThanOrEqual(photoMedia.y + photoMedia.height);
+
+ const photoFeaturedMedia = await box(cardParts(card(names.featuredPhoto)).media);
+ const plainFeaturedMedia = await box(cardParts(card(names.featuredPlain)).media);
+ expect(Math.abs(plainFeaturedMedia.height - photoFeaturedMedia.height)).toBeLessThan(1);
+ }
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await new PublicEventPage(page).goto(event.eventId, event.slug);
+ expect((await box(card(names.headlinerPlain))).height).toBeLessThan(200);
+ });
+});
diff --git a/e2e/tests/lineup/public-lineup.spec.ts b/e2e/tests/lineup/public-lineup.spec.ts
new file mode 100644
index 0000000000..6ebe100266
--- /dev/null
+++ b/e2e/tests/lineup/public-lineup.spec.ts
@@ -0,0 +1,211 @@
+import { readFile } from 'node:fs/promises';
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket, createRecurringLiveEvent } from '../../api/factory';
+import { PublicEventPage } from '../../pages/public-event.page';
+import { openEmbedHost, resolveWidgetFrame, widgetMarkup } from '../../pages/embedded-widget';
+import { uniqueName } from '../../utils/unique';
+
+const festivalSlots = [
+ { starts_at: '2030-06-14 10:00:00', ends_at: '2030-06-14 10:20:00', stage: 'Main Stage', title: 'Opening keynote on the future of independent festivals and the people who run them', people: [] },
+ { starts_at: '2030-06-14 11:00:00', ends_at: '2030-06-14 12:00:00', stage: 'Main Stage', title: 'Talk A', people: [] },
+ { starts_at: '2030-06-14 11:30:00', ends_at: '2030-06-14 12:30:00', stage: 'Main Stage', title: 'Talk B', people: [] },
+ { starts_at: '2030-06-14 10:00:00', ends_at: '2030-06-14 12:00:00', stage: 'Side Stage', title: 'Workshop', people: [] },
+ { starts_at: '2030-06-14 13:00:00', ends_at: '2030-06-14 13:05:00', stage: 'Side Stage', title: 'Lightning 1', people: [] },
+ { starts_at: '2030-06-14 13:05:00', ends_at: '2030-06-14 13:10:00', stage: 'Side Stage', title: 'Lightning 2', people: [] },
+ { starts_at: '2030-06-14 13:10:00', ends_at: '2030-06-14 13:15:00', stage: 'Side Stage', title: 'Lightning 3', people: [] },
+ { starts_at: '2030-06-14 18:00:00', ends_at: '2030-06-14 23:00:00', title: 'Food trucks', people: [] },
+ { starts_at: '2030-06-14 18:00:00', ends_at: '2030-06-15 02:00:00', title: 'Bar open', people: [] },
+ { starts_at: '2030-06-15 20:00:00', ends_at: '2030-06-15 21:00:00', stage: 'Main Stage', title: 'Closing party', people: [] },
+];
+
+test.describe('public lineup and schedule', () => {
+ test('the stages view keeps every session readable and clickable, and says which stage it is on', async ({ page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ await api.importLineup(event.eventId, { people: [], slots: festivalSlots });
+
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await new PublicEventPage(page).goto(event.eventId, event.slug);
+ const schedule = page.getByTestId('event-schedule-section');
+
+ const days = schedule.getByRole('group', { name: 'Days' }).getByRole('button');
+ await expect(days).toHaveCount(2);
+ await expect(days.first()).toHaveAttribute('aria-pressed', 'true');
+ await expect(days.last()).toHaveAttribute('aria-pressed', 'false');
+ await expect(schedule.getByRole('button', { name: 'All stages', pressed: true })).toBeVisible();
+ await expect(schedule.getByRole('button', { name: 'Side Stage', pressed: false })).toBeVisible();
+ await schedule.getByRole('button', { name: 'Stages', exact: true }).click();
+ await expect(schedule.getByRole('button', { name: 'Stages', exact: true, pressed: true })).toBeVisible();
+ await expect(schedule.getByRole('button', { name: 'List', exact: true, pressed: false })).toBeVisible();
+
+ const blocks = schedule.getByTestId('event-stage-block');
+ await expect(blocks).toHaveCount(9);
+ await expect(blocks.filter({ hasText: 'Talk B' })).toHaveAccessibleName(/Talk B.*Main Stage/);
+ await expect(blocks.filter({ hasText: 'Food trucks' })).toHaveAccessibleName(/Food trucks.*Everywhere/);
+
+ for (const block of await blocks.all()) {
+ await block.scrollIntoViewIfNeeded();
+ const title = block.locator('[class*="stageBlockTitle"]');
+ const titleBox = (await title.boundingBox())!;
+ const blockBox = (await block.boundingBox())!;
+ expect(titleBox.y + titleBox.height).toBeLessThanOrEqual(blockBox.y + blockBox.height + 0.5);
+
+ const topmost = await page.evaluate(([x, y]) => document.elementFromPoint(x, y)?.closest('[data-testid="event-stage-block"]')?.textContent ?? null,
+ [titleBox.x + Math.min(titleBox.width / 2, 12), titleBox.y + Math.min(titleBox.height / 2, 6)]);
+ expect(topmost).toBe(await block.textContent());
+ }
+
+ const hourLabels = schedule.locator('[class*="timeGutter"] span');
+ for (const label of await hourLabels.all()) {
+ expect((await label.boundingBox())!.height).toBeLessThan(20);
+ }
+ });
+
+ test('keyboard users get back to the session they opened and can add it to their calendar', async ({ page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const headliner = uniqueName('Keyboard Act');
+ await api.importLineup(event.eventId, {
+ people: [{ name: headliner, billing_tier: 'HEADLINER' }],
+ slots: [{ starts_at: '2030-06-14 21:30:00', ends_at: '2030-06-14 23:00:00', stage: 'Main Stage', people: [headliner] }],
+ });
+
+ await new PublicEventPage(page).goto(event.eventId, event.slug);
+
+ const sessionButton = page.getByTestId('event-schedule-item').getByRole('button', { name: headliner });
+ await sessionButton.focus();
+ await page.keyboard.press('Enter');
+ const session = page.getByTestId('session-modal');
+ await expect(session).toBeVisible();
+
+ await session.getByTestId('session-add-to-calendar').focus();
+ await page.keyboard.press('Enter');
+ await expect(page.getByRole('menuitem', { name: 'Google Calendar' })).toBeFocused();
+ await page.keyboard.press('ArrowDown');
+ await expect(page.getByRole('menuitem', { name: 'Download .ics' })).toBeFocused();
+ const [ics] = await Promise.all([
+ page.waitForEvent('download'),
+ page.keyboard.press('Enter'),
+ ]);
+ expect(await readFile(await ics.path(), 'utf8')).toContain('DTSTART:20300614T213000Z');
+ await expect(session.getByTestId('session-add-to-calendar')).toBeFocused();
+
+ await page.keyboard.press('Enter');
+ await expect(page.getByRole('menuitem', { name: 'Google Calendar' })).toBeFocused();
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('menuitem', { name: 'Google Calendar' })).toBeHidden();
+ await expect(session).toBeVisible();
+
+ await page.keyboard.press('Escape');
+ await expect(session).toBeHidden();
+ await expect(sessionButton).toBeFocused();
+
+ const card = page.getByTestId('event-lineup-section').getByRole('button', { name: `About ${headliner}` });
+ await card.focus();
+ await page.keyboard.press('Enter');
+ const profile = page.getByRole('dialog', { name: headliner });
+ await expect(profile.getByRole('heading', { name: headliner, level: 3 })).toBeVisible();
+ await profile.getByRole('button', { name: /Main Stage/ }).click();
+ await expect(page.getByTestId('session-modal')).toBeVisible();
+ await page.getByRole('dialog').getByRole('button', { name: 'Back' }).click();
+ await expect(profile.getByRole('heading', { name: headliner, level: 3 })).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(card).toBeFocused();
+ });
+
+ test('a photo-less headliner is a compact card, not an empty poster', async ({ page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const name = uniqueName('No Photo Headliner');
+ await api.importLineup(event.eventId, { people: [{ name, billing_tier: 'HEADLINER' }], slots: [] });
+
+ for (const viewport of [{ width: 390, height: 844 }, { width: 1440, height: 900 }]) {
+ await page.setViewportSize(viewport);
+ await new PublicEventPage(page).goto(event.eventId, event.slug);
+ const card = page.getByTestId('event-lineup-card').filter({ hasText: name });
+ await expect(card).toBeVisible();
+ expect((await card.boundingBox())!.height).toBeLessThan(200);
+ }
+ });
+
+ test('a link to one date of a recurring event renders that date’s lineup on the server', async ({ browser, api, account, baseURL }) => {
+ const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 });
+ const [first, second] = event.occurrences;
+ const firstAct = uniqueName('First Friday');
+ const secondAct = uniqueName('Second Friday');
+ const resident = uniqueName('Resident');
+ const slotOn = (occurrence: { start_date: string }, person: string) => {
+ const day = occurrence.start_date.slice(0, 10);
+ return { starts_at: `${day} 19:30:00`, ends_at: `${day} 20:30:00`, people: [person] };
+ };
+ await api.importLineup(event.eventId, {
+ people: [firstAct, secondAct, resident].map((name) => ({ name, billing_tier: 'FEATURED' as const })),
+ slots: [slotOn(first, firstAct), slotOn(second, secondAct)],
+ });
+ const url = `${baseURL}/event/${event.eventId}/${event.slug}?occurrence_id=${second.id}`;
+
+ const noScript = await browser.newContext({ javaScriptEnabled: false, ignoreHTTPSErrors: true });
+ const serverPage = await noScript.newPage();
+ await serverPage.goto(url);
+ const serverLineup = serverPage.getByTestId('event-lineup-section');
+ await expect(serverLineup).toContainText(secondAct);
+ await expect(serverLineup).toContainText(resident);
+ await expect(serverLineup).not.toContainText(firstAct);
+ await noScript.close();
+
+ const hydratedPage = await browser.newPage({ ignoreHTTPSErrors: true });
+ const consoleErrors: string[] = [];
+ hydratedPage.on('console', (message) => message.type() === 'error' && consoleErrors.push(message.text()));
+ await hydratedPage.goto(url);
+ await hydratedPage.waitForLoadState('networkidle');
+ await expect(hydratedPage.getByTestId('event-lineup-card')).toHaveCount(2);
+ await expect(hydratedPage.getByTestId('event-lineup-section')).not.toContainText(firstAct);
+ expect(consoleErrors.filter((message) => /Hydration|#418|#425/.test(message))).toEqual([]);
+ await hydratedPage.close();
+ });
+
+ test('with the schedule hidden, each date of a recurring event still shows only its own acts', async ({ page, api, account }) => {
+ const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 });
+ const [first, second] = event.occurrences;
+ const firstAct = uniqueName('First Friday');
+ const secondAct = uniqueName('Second Friday');
+ const resident = uniqueName('Resident');
+ const slotOn = (occurrence: { start_date: string }, person: string) => {
+ const day = occurrence.start_date.slice(0, 10);
+ return { starts_at: `${day} 19:30:00`, ends_at: `${day} 20:30:00`, people: [person] };
+ };
+ await api.importLineup(event.eventId, {
+ people: [firstAct, secondAct, resident].map((name) => ({ name, billing_tier: 'FEATURED' as const })),
+ slots: [slotOn(first, firstAct), slotOn(second, secondAct)],
+ });
+ await api.updateEventSettings(event.eventId, {
+ homepage_sections: [{ id: 'schedule', type: 'SCHEDULE', enabled: false }],
+ });
+
+ await page.goto(`/event/${event.eventId}/${event.slug}?occurrence_id=${second.id}`);
+ const lineup = page.getByTestId('event-lineup-section');
+ await expect(lineup).toContainText(secondAct);
+ await expect(lineup).toContainText(resident);
+ await expect(lineup).not.toContainText(firstAct);
+ await expect(page.getByTestId('event-schedule-item')).toHaveCount(0);
+
+ await page.goto(`/widget/${event.eventId}/lineup?OccurrenceId=${first.id}`);
+ await expect(page.getByTestId('lineup-widget')).toContainText(firstAct);
+ await expect(page.getByTestId('lineup-widget')).not.toContainText(secondAct);
+ });
+
+ test('the sections widget leaves the lineup out when the Lineup section is turned off', async ({ page, api, account, baseURL }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const name = uniqueName('Hidden Lineup Act');
+ await api.importLineup(event.eventId, {
+ people: [{ name, billing_tier: 'HEADLINER' }],
+ slots: [{ starts_at: '2030-06-14 21:30:00', ends_at: '2030-06-14 23:00:00', stage: 'Main Stage', people: [name] }],
+ });
+ await api.updateEventSettings(event.eventId, {
+ homepage_sections: [{ id: 'lineup', type: 'LINEUP', enabled: false }],
+ });
+
+ await openEmbedHost(page, baseURL!, widgetMarkup({ id: String(event.eventId), sections: 'lineup,schedule,tickets' }));
+ const widget = await resolveWidgetFrame(page);
+
+ await expect(widget.getByTestId('event-schedule-item')).toContainText(name);
+ await expect(widget.getByTestId('event-lineup-section')).toHaveCount(0);
+ });
+});
diff --git a/e2e/tests/lineup/public-lineup.unit.spec.ts b/e2e/tests/lineup/public-lineup.unit.spec.ts
new file mode 100644
index 0000000000..7f1a8770d4
--- /dev/null
+++ b/e2e/tests/lineup/public-lineup.unit.spec.ts
@@ -0,0 +1,141 @@
+import { expect, test } from '@playwright/test';
+import { assignScheduleLanes, initialSelectedOccurrence } from '../../../frontend/src/utilites/lineupSchedule';
+import { placeEmbeddedDialog } from '../../../frontend/src/components/widget/embeddedDialogPlacement';
+import { widgetFrameStyle } from '../../../frontend/src/utilites/widgetTheme';
+import { EventOccurrenceStatus, type EventOccurrence } from '../../../frontend/src/types';
+
+const occurrence = (id: number, overrides: Partial = {}): EventOccurrence => ({
+ id,
+ start_date: `2030-06-${String(10 + id).padStart(2, '0')} 19:00:00`,
+ status: EventOccurrenceStatus.ACTIVE,
+ is_past: false,
+ ...overrides,
+});
+
+const channel = (value: number) => {
+ const srgb = value / 255;
+ return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
+};
+
+const luminance = ([r, g, b]: number[]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
+
+const hexToRgb = (hex: string) => [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16));
+
+const mixedOver = (mix: string, background: string) => {
+ const [, color, percent] = mix.match(/color-mix\(in srgb, (#[0-9a-f]{6}) (\d+)%, transparent\)/i)!;
+ const alpha = Number(percent) / 100;
+ const foreground = hexToRgb(color);
+ return hexToRgb(background).map((base, index) => foreground[index] * alpha + base * (1 - alpha));
+};
+
+const contrast = (a: number[], b: number[]) => {
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (light + 0.05) / (dark + 0.05);
+};
+
+test.describe('public schedule stage lanes', () => {
+ test('slots that overlap on one stage sit side by side, and the rest keep the full width', () => {
+ const lanes = assignScheduleLanes([
+ { id: 1, top: 0, bottom: 480 },
+ { id: 2, top: 0, bottom: 800 },
+ { id: 3, top: 900, bottom: 960 },
+ { id: 4, top: 960, bottom: 988 },
+ ]);
+
+ expect(lanes.get(1)).toEqual({ lane: 0, lanes: 2 });
+ expect(lanes.get(2)).toEqual({ lane: 1, lanes: 2 });
+ expect(lanes.get(3)).toEqual({ lane: 0, lanes: 1 });
+ expect(lanes.get(4)).toEqual({ lane: 0, lanes: 1 });
+ });
+
+ test('a lane frees up once its slot has ended', () => {
+ const lanes = assignScheduleLanes([
+ { id: 1, top: 0, bottom: 100 },
+ { id: 2, top: 50, bottom: 200 },
+ { id: 3, top: 120, bottom: 180 },
+ ]);
+
+ expect(lanes.get(1)).toEqual({ lane: 0, lanes: 2 });
+ expect(lanes.get(2)).toEqual({ lane: 1, lanes: 2 });
+ expect(lanes.get(3)).toEqual({ lane: 0, lanes: 2 });
+ });
+});
+
+test.describe('initial occurrence on a public event page', () => {
+ test('a valid date from the link is used straight away', () => {
+ expect(initialSelectedOccurrence([occurrence(1), occurrence(2)], 2)?.id).toBe(2);
+ });
+
+ test('a single bookable date is picked without a link', () => {
+ const past = occurrence(1, { is_past: true });
+ const cancelled = occurrence(2, { status: EventOccurrenceStatus.CANCELLED });
+ expect(initialSelectedOccurrence([past, cancelled, occurrence(3)], null)?.id).toBe(3);
+ });
+
+ test('several dates and no link, or a link to a date that cannot be booked, picks nothing', () => {
+ expect(initialSelectedOccurrence([occurrence(1), occurrence(2)], null)).toBeUndefined();
+ expect(initialSelectedOccurrence([occurrence(1), occurrence(2, { is_past: true })], 2)?.id).toBe(1);
+ expect(initialSelectedOccurrence([occurrence(1), occurrence(2), occurrence(3, { is_past: true })], 3)).toBeUndefined();
+ });
+});
+
+test.describe('dialog placement inside an embedded widget', () => {
+ test('a dialog in a fully visible short frame asks for more room instead of shrinking for good', () => {
+ const placement = placeEmbeddedDialog(200, 370, { top: 0, bottom: 370 });
+
+ expect(placement.yOffset).toBe(16);
+ expect(placement.maxHeight).toBe(338);
+ expect(placement.frameHeight).toBe(104 + 640 + 16);
+ });
+
+ test('once the frame has grown, the dialog uses the part of it the visitor can see', () => {
+ const placement = placeEmbeddedDialog(200, 760, { top: 0, bottom: 600 });
+
+ expect(placement.yOffset).toBe(16);
+ expect(placement.maxHeight).toBe(568);
+ });
+
+ test('when the host page has scrolled past the top of the frame, the dialog opens below the visible top edge', () => {
+ const placement = placeEmbeddedDialog(300, 860, { top: 200, bottom: 860 });
+
+ expect(placement.yOffset).toBeGreaterThanOrEqual(216);
+ expect(placement.yOffset + placement.maxHeight).toBeLessThanOrEqual(860 - 16);
+ });
+
+ test('a tall frame that already fits the dialog asks for no extra room and stays near the click', () => {
+ const placement = placeEmbeddedDialog(2000, 3000, { top: 1200, bottom: 2100 });
+
+ expect(placement.frameHeight).toBe(0);
+ expect(placement.maxHeight).toBe(640);
+ expect(placement.yOffset).toBeGreaterThanOrEqual(1216);
+ expect(placement.yOffset + placement.maxHeight).toBeLessThanOrEqual(2100 - 16);
+ });
+
+ test('without a measurement the whole frame counts as visible', () => {
+ const placement = placeEmbeddedDialog(400, 900, null);
+
+ expect(placement.maxHeight).toBe(640);
+ expect(placement.yOffset).toBe(244);
+ expect(placement.frameHeight).toBe(0);
+ });
+});
+
+test.describe('widget theme contrast', () => {
+ const tertiaryContrast = (primaryText: string, background: string) => {
+ const style = widgetFrameStyle({
+ background,
+ primary: '#7b5db8',
+ primaryText,
+ secondary: '#7b5eb9',
+ secondaryText: '#ffffff',
+ bodyBackground: background,
+ }, {}, null) as Record;
+ return contrast(mixedOver(style['--event-secondary-text-color'], background), hexToRgb(background));
+ };
+
+ test('low-emphasis schedule text meets AA on the default widget and on the default event theme', () => {
+ expect(tertiaryContrast('#000000', '#ffffff')).toBeGreaterThanOrEqual(4.5);
+ expect(tertiaryContrast('#1a1a1a', '#ffffff')).toBeGreaterThanOrEqual(4.5);
+ expect(tertiaryContrast('#ffffff', '#1f1f1f')).toBeGreaterThanOrEqual(4.5);
+ });
+});
diff --git a/e2e/tests/management/app-shell.spec.ts b/e2e/tests/management/app-shell.spec.ts
new file mode 100644
index 0000000000..3781427bfa
--- /dev/null
+++ b/e2e/tests/management/app-shell.spec.ts
@@ -0,0 +1,75 @@
+import {expect, test} from '../../fixtures';
+import type {Page} from '@playwright/test';
+import {createLiveEventWithFreeTicket} from '../../api/factory';
+
+const PHONE = {width: 390, height: 844};
+const DESKTOP = {width: 1440, height: 900};
+
+const collectHydrationErrors = (page: Page): string[] => {
+ const errors: string[] = [];
+ page.on('pageerror', error => errors.push(error.message));
+ page.on('console', message => {
+ if (message.type() === 'error') {
+ errors.push(message.text());
+ }
+ });
+ return errors;
+};
+
+const waitForHydration = async (page: Page) => {
+ await expect.poll(() => page.evaluate(() => Array.from(document.querySelectorAll('#app div'))
+ .some(element => Object.keys(element).some(key => key.startsWith('__reactFiber$'))))).toBe(true);
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 250))));
+};
+
+test.describe('app shell', () => {
+ test('settings and event pages hydrate without errors on a phone', async ({authedPage: page, api, account}) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const errors = collectHydrationErrors(page);
+ await page.setViewportSize(PHONE);
+
+ for (const path of ['/settings/profile', `/manage/event/${event.eventId}/dashboard`]) {
+ errors.length = 0;
+ await page.goto(path);
+ await waitForHydration(page);
+
+ expect(errors.filter(error => /hydrat|#418|#423|#425/i.test(error)), path).toEqual([]);
+ }
+ });
+
+ test('on a phone the menu button is labelled and the closed navigation is out of reach', async ({authedPage: page}) => {
+ await page.setViewportSize(PHONE);
+ await page.goto('/settings/profile');
+ await waitForHydration(page);
+
+ await expect(page.getByRole('main')).toHaveCount(1);
+ const navigation = page.getByRole('navigation', {name: 'Main navigation'});
+ const menuButton = page.getByRole('button', {name: 'Open sidebar'});
+
+ await expect(navigation).toBeHidden();
+ await expect(menuButton).toHaveAttribute('aria-expanded', 'false');
+
+ await menuButton.click();
+ await expect(navigation).toBeVisible();
+ await expect(page.getByRole('button', {name: 'Close sidebar'}).first()).toHaveAttribute('aria-expanded', 'true');
+
+ await navigation.getByRole('link', {name: 'Security'}).click();
+ await expect(page).toHaveURL(/\/settings\/security$/);
+ await expect(navigation).toBeHidden();
+ });
+
+ test('the skip link moves focus to the page content', async ({authedPage: page}) => {
+ await page.setViewportSize(DESKTOP);
+ await page.goto('/settings/profile');
+ await waitForHydration(page);
+
+ await expect(page.getByRole('navigation', {name: 'Main navigation'})).toBeVisible();
+
+ const skipLink = page.getByRole('link', {name: 'Skip to main content'});
+ await skipLink.focus();
+ await expect(skipLink).toBeInViewport();
+ await page.keyboard.press('Enter');
+
+ await expect(page.getByRole('main')).toBeFocused();
+ });
+});
diff --git a/e2e/tests/management/date-time-field-default-time.unit.spec.ts b/e2e/tests/management/date-time-field-default-time.unit.spec.ts
new file mode 100644
index 0000000000..cf449d93f7
--- /dev/null
+++ b/e2e/tests/management/date-time-field-default-time.unit.spec.ts
@@ -0,0 +1,26 @@
+import { expect, test } from '@playwright/test';
+import { joinDateAndTime } from '../../../frontend/src/components/common/DateTimeField/dateTimeValue';
+
+test.describe('date time field default time', () => {
+ test('a date picked with no time on an end field closes at the end of that day', () => {
+ expect(joinDateAndTime('2030-12-24', '', '23:59')).toBe('2030-12-24T23:59');
+ });
+
+ test('a date picked with no time on a start field opens at midnight', () => {
+ expect(joinDateAndTime('2030-12-24', '', '00:00')).toBe('2030-12-24T00:00');
+ });
+
+ test('changing the date of a saved value keeps its time, including midnight', () => {
+ expect(joinDateAndTime('2030-12-25', '19:30', '23:59')).toBe('2030-12-25T19:30');
+ expect(joinDateAndTime('2030-12-25', '00:00', '23:59')).toBe('2030-12-25T00:00');
+ });
+
+ test('a time chosen before the date is kept', () => {
+ expect(joinDateAndTime('2030-12-24', '08:15', '23:59')).toBe('2030-12-24T08:15');
+ });
+
+ test('clearing the date clears the value', () => {
+ expect(joinDateAndTime('', '19:30', '23:59')).toBe('');
+ expect(joinDateAndTime('', '', '23:59')).toBe('');
+ });
+});
diff --git a/e2e/tests/management/date-time-field.spec.ts b/e2e/tests/management/date-time-field.spec.ts
new file mode 100644
index 0000000000..a6d780ce1d
--- /dev/null
+++ b/e2e/tests/management/date-time-field.spec.ts
@@ -0,0 +1,198 @@
+import type { Locator, Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createDraftEvent, createDraftEventWithTicket, createRecurringLiveEvent } from '../../api/factory';
+import { EventSettingsPage } from '../../pages/event-settings.page';
+import { OccurrencePage } from '../../pages/occurrence.page';
+import { ProductCreatePage } from '../../pages/product-create.page';
+import { ProductEditPage } from '../../pages/product-edit.page';
+import { PromoCodePage } from '../../pages/promo-code.page';
+import { expectDateTime, pickDate } from '../../utils/datePicker';
+import { uniqueCode } from '../../utils/unique';
+
+const datePicker = (page: Page): Locator =>
+ page.getByRole('dialog').filter({ has: page.locator('select[data-select="year"]') });
+
+const fieldWrapper = (dateField: Locator): Locator =>
+ dateField.locator('xpath=ancestor::div[contains(@class, "mantine-InputWrapper-root")][2]');
+
+async function expectOpensFromItsCentre(page: Page, dateField: Locator): Promise {
+ await dateField.scrollIntoViewIfNeeded();
+ const box = await dateField.boundingBox();
+ await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2);
+ await expect(datePicker(page)).toBeVisible({ timeout: 3_000 });
+ await page.keyboard.press('Escape');
+ await expect(datePicker(page)).toBeHidden();
+}
+
+async function expectDateOnOneLine(dateField: Locator): Promise {
+ await dateField.scrollIntoViewIfNeeded();
+ const date = await dateField.boundingBox();
+ const time = await fieldWrapper(dateField).locator('.mantine-TimePicker-input').boundingBox();
+ expect(date!.width).toBeGreaterThanOrEqual(150);
+ expect(Math.round(date!.height)).toBe(Math.round(time!.height));
+ expect(await dateField.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
+}
+
+async function expectStandaloneDateOnOneLine(dateField: Locator): Promise {
+ await dateField.scrollIntoViewIfNeeded();
+ const date = await dateField.boundingBox();
+ expect(Math.round(date!.height)).toBe(36);
+ expect(await dateField.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
+}
+
+test.describe('date and time fields', () => {
+ test.describe('in two-column forms at 1440px', () => {
+ test.use({ viewport: { width: 1440, height: 900 } });
+
+ test('a price tier sale date opens from its centre and shows the whole date on one line', async ({ authedPage, api, account }) => {
+ const event = await createDraftEvent(api, account.organizerId);
+ const products = new ProductCreatePage(authedPage);
+ await products.goto(event.eventId);
+ await products.openCreateModal();
+ await products.selectPriceType('Tiers');
+
+ const saleStart = authedPage.getByLabel('Sale Start Date', { exact: true }).first();
+ await expectOpensFromItsCentre(authedPage, saleStart);
+ await pickDate(authedPage, saleStart, '2030-11-18');
+ await expectDateOnOneLine(saleStart);
+ });
+
+ test('the product sale window shows the whole date on one line', async ({ authedPage, api, account }) => {
+ const event = await createDraftEvent(api, account.organizerId);
+ const products = new ProductCreatePage(authedPage);
+ await products.goto(event.eventId);
+ await products.openCreateModal();
+ await products.openLedgerRow('sale-window');
+
+ const saleStart = authedPage.getByLabel('Sale Start Date', { exact: true });
+ await expectOpensFromItsCentre(authedPage, saleStart);
+ await pickDate(authedPage, saleStart, '2030-11-18');
+ await expectDateOnOneLine(saleStart);
+ });
+
+ test('a date-only pick opens the product sale window at midnight and closes it at 23:59', async ({ authedPage, api, account }) => {
+ const event = await createDraftEvent(api, account.organizerId);
+ const products = new ProductCreatePage(authedPage);
+ await products.goto(event.eventId);
+ await products.openCreateModal();
+ await products.openLedgerRow('sale-window');
+
+ const saleStart = authedPage.getByLabel('Sale Start Date', { exact: true });
+ const saleEnd = authedPage.getByLabel('Sale End Date', { exact: true });
+ await pickDate(authedPage, saleStart, '2030-12-01');
+ await pickDate(authedPage, saleEnd, '2030-12-24');
+
+ await expectDateTime(authedPage, saleStart, '2030-12-01T00:00');
+ await expectDateTime(authedPage, saleEnd, '2030-12-24T23:59');
+ });
+
+ test('the Edit Date modal shows the whole start date on one line', async ({ authedPage, api, account }) => {
+ const event = await createRecurringLiveEvent(api, account.organizerId, { count: 2 });
+ const occurrences = new OccurrencePage(authedPage);
+ await occurrences.goto(event.eventId);
+ await occurrences.chooseRowAction(occurrences.occurrenceRows().first(), 'Edit');
+
+ const start = authedPage.getByRole('dialog').getByLabel(/^Start Date/);
+ await expect(start).toContainText(/\d{4}/);
+ await expectDateOnOneLine(start);
+ await expectOpensFromItsCentre(authedPage, start);
+ });
+
+ test('the schedule drawer shows its from and until dates on one line', async ({ authedPage, api, account }) => {
+ const event = await createRecurringLiveEvent(api, account.organizerId, { count: 2 });
+ const occurrences = new OccurrencePage(authedPage);
+ await occurrences.goto(event.eventId);
+ await authedPage.getByRole('button', { name: /Add Dates/ }).first().click();
+ await authedPage.getByRole('menuitem', { name: /Edit Schedule|Set Up Schedule/ }).click();
+ await authedPage.getByRole('dialog').getByText('Until a date', { exact: true }).click();
+
+ const from = authedPage.getByRole('dialog').getByLabel('Schedule starts on');
+ const until = authedPage.getByRole('dialog').getByLabel('Schedule ends on');
+ await pickDate(authedPage, from, '2030-11-28');
+ await pickDate(authedPage, until, '2031-05-28');
+ await expectStandaloneDateOnOneLine(from);
+ await expectStandaloneDateOnOneLine(until);
+ });
+ });
+
+ test.describe('in event settings at 768px', () => {
+ test.use({ viewport: { width: 768, height: 1024 } });
+
+ test('the start date opens from its centre and shows the whole date on one line', async ({ authedPage, api, account }) => {
+ const event = await createDraftEvent(api, account.organizerId);
+ const settings = new EventSettingsPage(authedPage);
+ await settings.goto(event.eventId);
+
+ await expect(settings.detailsStartDate).toContainText(/\d{4}/);
+ await expectDateOnOneLine(settings.detailsStartDate);
+ await expectOpensFromItsCentre(authedPage, settings.detailsStartDate);
+ });
+ });
+
+ test('a keyboard pick keeps focus on the field, which announces its value, and each time part names its field', async ({ authedPage, api, account }) => {
+ const event = await createDraftEvent(api, account.organizerId);
+ const settings = new EventSettingsPage(authedPage);
+ await settings.goto(event.eventId);
+
+ const start = settings.detailsStartDate;
+ await start.focus();
+ await authedPage.keyboard.press('Enter');
+ const picker = datePicker(authedPage);
+ await expect(picker).toBeVisible();
+ await picker.locator('select[data-select="year"]').selectOption('2030');
+ await picker.locator('select[data-select="month"]').selectOption('5');
+ await picker.getByRole('button', { name: '14 June 2030', exact: true }).focus();
+ await authedPage.keyboard.press('Enter');
+ await expect(picker).toBeHidden();
+
+ await expect(start).toBeFocused();
+ await expect(start).toHaveAccessibleName('Start Date Jun 14, 2030');
+
+ const end = settings.detailsEndDate;
+ await pickDate(authedPage, end, '2030-06-15');
+ await expect(fieldWrapper(end).getByRole('button', { name: 'Clear', exact: true })).toBeVisible();
+
+ await expect(fieldWrapper(start).getByLabel('Hours')).toHaveAccessibleDescription('Start Date');
+ await expect(fieldWrapper(end).getByLabel('Hours')).toHaveAccessibleDescription('End Date');
+ await expect(fieldWrapper(end).getByLabel('Minutes')).toHaveAccessibleDescription('End Date');
+ await expect(fieldWrapper(end).getByLabel('AM/PM')).toHaveAccessibleDescription('End Date');
+ });
+
+ test('the edit promo code modal shows the saved expiry time inside the collapsed advanced options', async ({ authedPage, api, account }) => {
+ const event = await createDraftEvent(api, account.organizerId);
+ const code = uniqueCode('EXPIRY');
+ await api.createPromoCode(event.eventId, {
+ code,
+ discount_type: 'PERCENTAGE',
+ discount: 10,
+ expiry_date: '2030-12-31 19:30:00',
+ });
+
+ const promoCodes = new PromoCodePage(authedPage);
+ await promoCodes.goto(event.eventId);
+ await promoCodes.openEditModal(code);
+
+ await expectDateTime(authedPage, authedPage.getByLabel('Expiry Date'), '2030-12-31T19:30');
+ });
+
+ test('the edit product drawer shows the saved sale window times when the row is expanded', async ({ authedPage, api, account }) => {
+ const seeded = await createDraftEventWithTicket(api, account.organizerId, { productTitle: 'Timed Ticket' });
+ await api.createProduct(seeded.eventId, {
+ title: 'Windowed Ticket',
+ product_type: 'TICKET',
+ type: 'FREE',
+ product_category_id: seeded.categoryId,
+ prices: [{ price: 0 }],
+ sale_start_date: '2030-05-01 09:15:00',
+ sale_end_date: '2030-05-20 21:45:00',
+ });
+
+ const products = new ProductEditPage(authedPage);
+ await products.goto(seeded.eventId);
+ await products.openProductByTitle('Windowed Ticket');
+ await authedPage.getByTestId('product-ledger-sale-window').click();
+
+ await expectDateTime(authedPage, authedPage.getByLabel('Sale Start Date', { exact: true }), '2030-05-01T09:15');
+ await expectDateTime(authedPage, authedPage.getByLabel('Sale End Date', { exact: true }), '2030-05-20T21:45');
+ });
+});
diff --git a/e2e/tests/management/designer-widget-controls.spec.ts b/e2e/tests/management/designer-widget-controls.spec.ts
new file mode 100644
index 0000000000..4af85b6910
--- /dev/null
+++ b/e2e/tests/management/designer-widget-controls.spec.ts
@@ -0,0 +1,119 @@
+import type { Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket } from '../../api/factory';
+import { DesignerPage } from '../../pages/designer.page';
+import { LineupPage } from '../../pages/lineup.page';
+import { invitedMember } from '../../utils/team';
+import { uniqueName } from '../../utils/unique';
+
+const SECTION_ERROR = 'Use a YouTube or Vimeo link starting with https://';
+
+const missingDescriptions = (page: Page, containerSelector: string) =>
+ page.evaluate((selector) => {
+ const elements = Array.from(document.querySelectorAll(`${selector} [aria-describedby]`));
+ return {
+ count: elements.length,
+ missing: elements
+ .map((element) => element.getAttribute('aria-describedby') ?? '')
+ .filter((id) => !document.getElementById(id)),
+ };
+ }, containerSelector);
+
+const rejectSettingsSave = (page: Page, path: RegExp) =>
+ page.route(path, (route) => {
+ if (route.request().method() !== 'PATCH') {
+ return route.fallback();
+ }
+ return route.fulfill({
+ status: 422,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ message: SECTION_ERROR,
+ errors: { 'homepage_sections.2.video_url': [SECTION_ERROR] },
+ }),
+ });
+ });
+
+test.describe('designer and widget editor controls', () => {
+ test('lineup and homepage section controls say which row they act on', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const name = uniqueName('Grip Named');
+ const profile = await api.createLineupProfile(account.organizerId, { name });
+ await api.addLineupEntry(event.eventId, { profile_id: profile.id, billing_tier: 'HEADLINER' });
+
+ const lineup = new LineupPage(page);
+ await lineup.goto(event.eventId);
+ await expect(lineup.person(name).getByRole('button', { name: `Drag to reorder ${name}`, exact: true })).toBeVisible();
+ const railDescriptions = await missingDescriptions(page, '[data-testid="lineup-people-list"]');
+ expect(railDescriptions.count).toBeGreaterThan(0);
+ expect(railDescriptions.missing).toEqual([]);
+
+ const designer = new DesignerPage(page);
+ await designer.gotoHomepageDesigner(event.eventId);
+ const about = designer.sectionRow('about');
+ await expect(about.getByRole('button', { name: 'Hide About', exact: true })).toBeVisible();
+ await expect(about.getByRole('button', { name: 'Section settings for About', exact: true })).toBeVisible();
+ await designer.toggleSection('about');
+ await expect(about.getByRole('button', { name: 'Show About', exact: true })).toBeVisible();
+ });
+
+ test('the widget editor names its choices and keeps drag instructions after hydration', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ await page.goto(`/manage/event/${event.eventId}/widget`);
+ await expect(page.frameLocator('iframe[title="Widget preview"]').getByRole('heading', { name: event.productTitle })).toBeVisible();
+
+ await page.getByTestId('widget-group-sections').click();
+ await expect(page.getByTestId('widget-section-about-switch')).toBeVisible();
+ const pickerDescriptions = await missingDescriptions(page, 'ul:has([data-testid="widget-section-about-switch"])');
+ expect(pickerDescriptions.count).toBeGreaterThan(0);
+ expect(pickerDescriptions.missing).toEqual([]);
+
+ await page.getByTestId('widget-group-appearance').click();
+ await expect(page.getByRole('radiogroup', { name: 'Colours and font' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Edit design' })).toBeVisible();
+
+ await page.getByTestId('widget-group-behaviour').click();
+ await expect(page.getByRole('radiogroup', { name: 'Checkout opens in' })).toBeVisible();
+ });
+
+ test('a rejected homepage section shows the server message', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const designer = new DesignerPage(page);
+
+ await designer.gotoHomepageDesigner(event.eventId);
+ await rejectSettingsSave(page, new RegExp(`/events/${event.eventId}/settings$`));
+ await designer.toggleSection('about');
+ await designer.saveButton.click();
+ await expect(page.getByText(SECTION_ERROR)).toBeVisible();
+ await expect(page.getByText('Please check the provided information is correct')).toHaveCount(0);
+
+ await designer.gotoOrganizerHomepageDesigner(account.organizerId);
+ await rejectSettingsSave(page, new RegExp(`/organizers/${account.organizerId}/settings$`));
+ await designer.toggleSection('about');
+ await designer.saveButton.click();
+ await expect(page.getByText(SECTION_ERROR)).toBeVisible();
+ await expect(page.getByText('Please check the provided information is correct')).toHaveCount(0);
+ });
+
+ test('a viewer is not offered designer links or event creation', async ({ freshAccount, mailpit, publicApi, browser }) => {
+ const event = await createLiveEventWithFreeTicket(freshAccount.api, freshAccount.organizerId);
+ await freshAccount.api.updateEventSettings(event.eventId, {
+ homepage_sections: [{ id: 'tickets', type: 'TICKETS', enabled: true }, { id: 'lineup', type: 'LINEUP', enabled: false }],
+ });
+
+ const { page } = await invitedMember(freshAccount, mailpit, publicApi, browser, { role: 'VIEWER' });
+
+ await page.goto(`/manage/organizer/${freshAccount.organizerId}/events/archived`);
+ await expect(page.getByText('No archived events to show.')).toBeVisible();
+ await expect(page.getByTestId('create-event-blank-slate-button')).toHaveCount(0);
+
+ await page.goto(`/manage/event/${event.eventId}/widget`);
+ await page.getByTestId('widget-group-appearance').click();
+ await expect(page.getByTestId('widget-style-control')).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Edit design' })).toHaveCount(0);
+
+ await page.getByTestId('widget-type-lineup').click();
+ await expect(page.getByText('Your lineup is hidden on your event page, so this widget will be empty.')).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Open homepage designer' })).toHaveCount(0);
+ });
+});
diff --git a/e2e/tests/management/dialog-accessibility.spec.ts b/e2e/tests/management/dialog-accessibility.spec.ts
new file mode 100644
index 0000000000..f6d2bfee40
--- /dev/null
+++ b/e2e/tests/management/dialog-accessibility.spec.ts
@@ -0,0 +1,190 @@
+import type { Locator, Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import type { FreshAccount } from '../../fixtures';
+import { createDraftEvent, createLiveEventWithFreeTicket } from '../../api/factory';
+import { EnterpriseSsoSettings } from '../../pages/enterprise-sso.page';
+import { enableFeatureFlag } from '../../utils/db';
+import { uniqueCode, uniqueEmail, uniqueName } from '../../utils/unique';
+
+const expectFocusInside = async (page: Page, dialog: Locator) => {
+ const handle = await dialog.elementHandle();
+ await expect.poll(() => page.evaluate((element) => !!element && element.contains(document.activeElement), handle)).toBe(true);
+};
+
+const chooseFromMenu = async (page: Page, menuButton: Locator, item: string) => {
+ await menuButton.focus();
+ await page.keyboard.press('Enter');
+ const menuItem = page.getByRole('menuitem', { name: item });
+ await expect(menuItem).toBeVisible();
+ await menuItem.focus();
+ await page.keyboard.press('Enter');
+};
+
+const openLineupProfile = async (account: FreshAccount, page: Page) => {
+ const event = await createLiveEventWithFreeTicket(account.api, account.organizerId);
+ const name = uniqueName('Drawer Focus');
+ const profile = await account.api.createLineupProfile(account.organizerId, { name });
+ const entry = await account.api.addLineupEntry(event.eventId, { profile_id: profile.id, billing_tier: 'STANDARD' });
+
+ await page.goto(`/manage/event/${event.eventId}/lineup`);
+ const trigger = page.getByTestId(`lineup-entry-${entry.id}`).getByRole('button', { name: new RegExp(name) }).filter({ hasText: name });
+ await trigger.focus();
+ await page.keyboard.press('Enter');
+ await expect(page.getByTestId('lineup-profile-save-button')).toBeVisible();
+
+ return { name, trigger };
+};
+
+test.describe('shared modal and drawer accessibility', () => {
+ test('a create modal names its close button and gives focus back to the button that opened it', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ await page.goto('/settings/team');
+
+ const invite = page.getByTestId('team-invite-button');
+ await invite.focus();
+ await page.keyboard.press('Enter');
+
+ const dialog = page.getByRole('dialog', { name: 'Invite a team member' });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByRole('button', { name: 'Close', exact: true })).toBeVisible();
+
+ await page.keyboard.press('Escape');
+ await expect(dialog).toBeHidden();
+ await expect(invite).toBeFocused();
+ });
+
+ for (const width of [390, 1440]) {
+ test(`at ${width}px a dialog opened from a row menu gives focus back to the menu button`, async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ await page.setViewportSize({ width, height: 900 });
+ const event = await createDraftEvent(freshAccount.api, freshAccount.organizerId);
+ const code = uniqueCode('FOCUS');
+ await freshAccount.api.createPromoCode(event.eventId, { code, discount_type: 'PERCENTAGE', discount: 10 });
+
+ await page.goto('/settings/team');
+ const memberMenu = page.getByTestId('team-member-actions').first();
+ await chooseFromMenu(page, memberMenu, 'Edit user');
+ const editMember = page.getByRole('dialog', { name: 'Edit member' });
+ await expect(editMember).toBeVisible();
+ await expectFocusInside(page, editMember);
+ await page.keyboard.press('Escape');
+ await expect(editMember).toBeHidden();
+ await expect(memberMenu).toBeFocused();
+
+ await page.goto(`/manage/event/${event.eventId}/promo-codes`);
+ const promoMenu = page.getByRole('row').filter({ hasText: code.toUpperCase() }).getByTestId('promo-code-actions-button');
+ await chooseFromMenu(page, promoMenu, 'Edit Code');
+ const editCode = page.getByRole('dialog', { name: 'Edit Promo Code' });
+ await expect(editCode).toBeVisible();
+ await expectFocusInside(page, editCode);
+ await page.keyboard.press('Escape');
+ await expect(editCode).toBeHidden();
+ await expect(promoMenu).toBeFocused();
+ });
+ }
+
+ test('a side drawer is named by its heading and gives focus back to the row that opened it', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const { name, trigger } = await openLineupProfile(freshAccount, page);
+
+ const drawer = page.getByRole('dialog', { name });
+ await expect(drawer).toBeVisible();
+
+ await page.keyboard.press('Escape');
+ await expect(drawer).toBeHidden();
+ await expect(trigger).toBeFocused();
+ });
+
+ test('on a phone, a long drawer keeps Close in view and its footer above field icons', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ await page.setViewportSize({ width: 390, height: 640 });
+ await openLineupProfile(freshAccount, page);
+
+ const drawer = page.getByRole('dialog').filter({ has: page.getByTestId('lineup-profile-save-button') });
+ await drawer.evaluate((element) => { element.scrollTop = element.scrollHeight; });
+ await expect(drawer.getByRole('button', { name: 'Close', exact: true })).toBeInViewport();
+
+ const cancel = drawer.getByRole('button', { name: 'Cancel', exact: true });
+ const linkIcon = drawer.getByRole('textbox', { name: 'Link 1' }).locator('xpath=..').locator('[data-position="left"]');
+ const iconBox = await linkIcon.boundingBox();
+ const cancelBox = await cancel.boundingBox();
+ expect(iconBox && cancelBox).toBeTruthy();
+ await drawer.evaluate((element, delta) => { element.scrollTop += delta; }, (iconBox!.y + iconBox!.height / 2) - (cancelBox!.y + cancelBox!.height / 2));
+
+ const stacking = await cancel.evaluate((button, icon) => {
+ if (!icon) {
+ return null;
+ }
+ const box = button.getBoundingClientRect();
+ const iconRect = icon.getBoundingClientRect();
+ const iconCentreY = iconRect.top + iconRect.height / 2;
+ const hit = document.elementFromPoint(iconRect.left + iconRect.width / 2, box.top + box.height / 2);
+ return {
+ iconUnderCancel: iconCentreY > box.top && iconCentreY < box.bottom,
+ cancelOnTop: !!hit && button.contains(hit),
+ };
+ }, await linkIcon.elementHandle());
+ expect(stacking).toEqual({ iconUnderCancel: true, cancelOnTop: true });
+ });
+
+ test('the single sign-on drawer is named and its copy button has a name', async ({ freshAccount }) => {
+ const { id } = await freshAccount.api.getAccount();
+ enableFeatureFlag(id, 'enterprise_sso');
+ const page = await freshAccount.newAuthedPage();
+ const settings = new EnterpriseSsoSettings(page);
+ await settings.open();
+
+ const drawer = page.getByRole('dialog', { name: 'Single sign-on' });
+ await expect(drawer).toBeVisible();
+
+ await page.getByTestId('sso-provider-card-OIDC').click();
+ await expect(drawer.getByRole('button', { name: 'Copy redirect URI', exact: true })).toBeVisible();
+ });
+
+ test('saving settings with the keyboard keeps focus on the save button', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ await page.goto('/settings/profile');
+
+ const firstName = page.getByRole('textbox', { name: 'First name' });
+ await expect(firstName).toHaveValue('E2E');
+ await firstName.fill('Keyboard');
+
+ const save = page.getByTestId('profile-save-button');
+ await save.focus();
+ await page.keyboard.press('Enter');
+
+ await expect(page.getByText('Profile updated successfully')).toBeVisible();
+ await expect(save).toBeFocused();
+ await expect(save).toBeDisabled();
+ });
+
+ test('a failed save inside the confirm-identity dialog does not move focus behind the dialog', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ await page.goto('/settings/profile');
+
+ await expect(page.getByRole('textbox', { name: 'First name' })).toHaveValue('E2E');
+ await page.locator('#profile-email').fill(uniqueEmail('focus'));
+
+ const save = page.getByTestId('profile-save-button');
+ await save.focus();
+ await page.keyboard.press('Enter');
+
+ const dialog = page.getByRole('dialog', { name: "Confirm it's you" });
+ await expect(dialog).toBeVisible();
+ await dialog.getByLabel('Current password').fill('definitely-wrong-password');
+
+ const confirm = dialog.getByTestId('confirm-identity-submit');
+ await confirm.focus();
+ await page.keyboard.press('Enter');
+
+ await expect(dialog.getByText('The password is incorrect.')).toBeVisible();
+ await expect(confirm).toBeEnabled();
+ await expect(dialog).toBeVisible();
+ await expect(save).not.toBeFocused();
+ const focusOutsideDialog = await page.evaluate(() => {
+ const active = document.activeElement;
+ return !!active && active !== document.body && !active.closest('[role="dialog"]');
+ });
+ expect(focusOutsideDialog).toBe(false);
+ });
+});
diff --git a/e2e/tests/management/homepage-designer-limits.spec.ts b/e2e/tests/management/homepage-designer-limits.spec.ts
new file mode 100644
index 0000000000..92b4789caf
--- /dev/null
+++ b/e2e/tests/management/homepage-designer-limits.spec.ts
@@ -0,0 +1,151 @@
+import fs from 'node:fs';
+import type { Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket } from '../../api/factory';
+import { DesignerPage } from '../../pages/designer.page';
+
+const LOGO = fs.readFileSync(new URL('../../fixtures/assets/gallery-photo.png', import.meta.url));
+
+const uploadSponsorLogo = async (page: Page, eventId: number): Promise => {
+ const response = await page.request.post('/api/images', {
+ headers: { Accept: 'application/json' },
+ multipart: { image: { name: 'logo.png', mimeType: 'image/png', buffer: LOGO }, image_type: 'EVENT_SPONSOR_LOGO', entity_id: String(eventId) },
+ });
+ expect(response.ok(), 'sponsor logo upload').toBeTruthy();
+ return (await response.json()).data.id;
+};
+
+test.describe('homepage designer limits', () => {
+ test('save is held back and the field is shown while an FAQ answer is over the limit', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const designer = new DesignerPage(page);
+ await designer.gotoHomepageDesigner(event.eventId);
+
+ const faq = await designer.addSection('faq');
+ await faq.getByTestId('homepage-faq-add').click();
+ await faq.getByLabel('Question 1').fill('Is there parking?');
+ await faq.getByLabel('Answer 1').fill('a'.repeat(5001));
+ await expect(faq.getByText('This answer is too long. Shorten it before saving.')).toBeVisible();
+ await faq.getByRole('button', { name: 'Section settings for FAQ' }).click();
+ await expect(faq.getByLabel('Answer 1')).toHaveCount(0);
+
+ const settingsWrites: string[] = [];
+ page.on('request', (request) => {
+ if (request.method() !== 'GET' && request.url().includes(`/events/${event.eventId}/settings`)) {
+ settingsWrites.push(request.url());
+ }
+ });
+
+ await designer.saveButton.click();
+
+ await expect(faq.getByLabel('Answer 1')).toBeFocused();
+ await expect(faq.getByText('This answer is too long. Shorten it before saving.')).toBeVisible();
+ await expect(designer.unsavedStatus).toBeVisible();
+ expect(settingsWrites).toEqual([]);
+
+ await faq.getByLabel('Answer 1').fill('Yes, free parking opens two hours before doors.');
+ await designer.saveButton.click();
+ await expect(designer.savedStatus).toBeVisible();
+ expect(settingsWrites).toHaveLength(1);
+
+ const settings = await api.getEventSettings(event.eventId);
+ const sections = settings.homepage_sections as { type: string; faqs?: { answer: string }[] }[];
+ expect(sections.find((section) => section.type === 'FAQ')?.faqs?.[0]?.answer).toBe('Yes, free parking opens two hours before doors.
');
+ });
+
+ test('save is held back while a pasted Text section body is over the limit', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const designer = new DesignerPage(page);
+ await designer.gotoHomepageDesigner(event.eventId);
+
+ const text = await designer.addSection('text');
+ const body = text.locator('[contenteditable="true"]');
+ await body.click();
+ await body.evaluate((element, pasted) => {
+ const data = new DataTransfer();
+ data.setData('text/plain', pasted);
+ element.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
+ }, 'a'.repeat(50001));
+ const limitError = text.getByText(/HTML character limit exceeded: \d+\/50000/);
+ await expect(limitError).toBeVisible();
+ await text.getByRole('button', { name: 'Section settings for Text' }).click();
+ await expect(body).toHaveCount(0);
+
+ const settingsWrites: string[] = [];
+ page.on('request', (request) => {
+ if (request.method() !== 'GET' && request.url().includes(`/events/${event.eventId}/settings`)) {
+ settingsWrites.push(request.url());
+ }
+ });
+
+ await designer.saveButton.click();
+
+ await expect(body).toBeFocused();
+ await expect(limitError).toBeInViewport();
+ await expect(designer.unsavedStatus).toBeVisible();
+ expect(settingsWrites).toEqual([]);
+
+ await body.fill('Doors open at seven.');
+ await expect(limitError).toHaveCount(0);
+ await designer.saveButton.click();
+ await expect(designer.savedStatus).toBeVisible();
+ expect(settingsWrites).toHaveLength(1);
+
+ const settings = await api.getEventSettings(event.eventId);
+ const sections = settings.homepage_sections as { type: string; body?: string }[];
+ expect(sections.find((section) => section.type === 'TEXT')?.body).toContain('Doors open at seven.');
+ });
+
+ test('the section limit explains why Add section is unavailable', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const builtIns = ['TICKETS', 'ABOUT', 'LINEUP', 'SCHEDULE', 'LOCATION', 'ORGANIZER'];
+ await api.updateEventSettings(event.eventId, {
+ homepage_sections: [
+ ...builtIns.map((type) => ({ id: type.toLowerCase(), type, enabled: true })),
+ ...Array.from({ length: 40 - builtIns.length }, (_, index) => ({ id: `text_${index}`, type: 'TEXT', enabled: true, heading: `Text ${index + 1}`, body: 'Hello
' })),
+ ],
+ });
+ const designer = new DesignerPage(page);
+ await designer.gotoHomepageDesigner(event.eventId);
+ await expect(designer.sectionRow('text')).toHaveCount(34);
+
+ const addButton = page.getByTestId('homepage-add-section-button');
+ await expect(addButton).toHaveAttribute('aria-disabled', 'true');
+ await addButton.hover();
+ await expect(page.getByRole('tooltip', { name: 'You can add up to 40 sections.' })).toBeVisible();
+ await addButton.click({ force: true });
+ await expect(page.getByTestId('homepage-add-section-text')).toHaveCount(0);
+
+ await designer.sectionRow('text').last().getByRole('button', { name: 'Section settings for Text 34' }).click();
+ await designer.sectionRow('text').last().getByRole('button', { name: 'Remove section' }).click();
+ await expect(addButton).not.toHaveAttribute('aria-disabled');
+ await addButton.click();
+ await expect(page.getByTestId('homepage-add-section-text')).toBeVisible();
+ });
+
+ test('the sponsor limit replaces the upload area with a note', async ({ authedPage: page, api, account }) => {
+ test.setTimeout(180_000);
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const logos: number[] = [];
+ for (let batch = 0; batch < 6; batch++) {
+ logos.push(...(await Promise.all(Array.from({ length: 10 }, () => uploadSponsorLogo(page, event.eventId)))));
+ }
+ await api.updateEventSettings(event.eventId, {
+ homepage_sections: [
+ { id: 'tickets', type: 'TICKETS', enabled: true },
+ { id: 'sponsors', type: 'SPONSORS', enabled: true, sponsors: logos.map((image_id, index) => ({ image_id, name: `Sponsor ${index + 1}` })) },
+ ],
+ });
+ const designer = new DesignerPage(page);
+ await designer.gotoHomepageDesigner(event.eventId);
+
+ const sponsors = designer.sectionRow('sponsors');
+ await sponsors.getByRole('button', { name: 'Section settings for Sponsors' }).click();
+ await expect(sponsors.getByTestId('homepage-sponsors-limit')).toHaveText("You've added the maximum of 60 sponsors.");
+ await expect(sponsors.getByText('Upload sponsor logos')).toHaveCount(0);
+
+ await sponsors.getByRole('button', { name: 'Remove sponsor' }).last().click();
+ await expect(sponsors.getByTestId('homepage-sponsors-limit')).toHaveCount(0);
+ await expect(sponsors.getByText('Upload sponsor logos')).toBeVisible();
+ });
+});
diff --git a/e2e/tests/management/homepage-media-focus.spec.ts b/e2e/tests/management/homepage-media-focus.spec.ts
new file mode 100644
index 0000000000..a86fbba076
--- /dev/null
+++ b/e2e/tests/management/homepage-media-focus.spec.ts
@@ -0,0 +1,49 @@
+import fs from 'node:fs';
+import type { Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket } from '../../api/factory';
+import { waitForHydration } from '../../utils/hydration';
+
+const PHOTO = fs.readFileSync(new URL('../../fixtures/assets/gallery-photo.png', import.meta.url));
+
+const uploadGalleryPhoto = async (page: Page, eventId: number): Promise => {
+ const response = await page.request.post('/api/images', {
+ headers: { Accept: 'application/json' },
+ multipart: { image: { name: 'photo.png', mimeType: 'image/png', buffer: PHOTO }, image_type: 'EVENT_GALLERY', entity_id: String(eventId) },
+ });
+ expect(response.ok()).toBeTruthy();
+ return (await response.json()).data.id;
+};
+
+test.describe('homepage media and keyboard focus', () => {
+ test('closing the photo viewer returns focus to the photo, and playing a video moves focus to the player', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const photos = [await uploadGalleryPhoto(page, event.eventId), await uploadGalleryPhoto(page, event.eventId)];
+ await api.updateEventSettings(event.eventId, {
+ homepage_sections: [
+ { id: 'gallery', type: 'GALLERY', enabled: true, image_ids: photos },
+ { id: 'video', type: 'VIDEO', enabled: true, video_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
+ ],
+ });
+ await page.route('https://www.youtube-nocookie.com/**', route => route.fulfill({ contentType: 'text/html', body: '' }));
+
+ await page.goto(`/event/${event.eventId}/${event.slug}`);
+ await waitForHydration(page, '[data-testid="homepage-section-gallery"] button');
+
+ const secondPhoto = page.getByTestId('homepage-section-gallery').getByRole('button', { name: 'Open photo 2' });
+ await secondPhoto.focus();
+ await page.keyboard.press('Enter');
+ const viewer = page.getByRole('dialog', { name: 'Photos' });
+ await expect(viewer).toContainText('2 / 2');
+ await page.keyboard.press('ArrowRight');
+ await expect(viewer).toContainText('1 / 2');
+ await page.keyboard.press('Escape');
+ await expect(viewer).toBeHidden();
+ await expect(secondPhoto).toBeFocused();
+
+ const video = page.getByTestId('homepage-section-video');
+ await video.getByRole('button', { name: 'Plays from YouTube' }).focus();
+ await page.keyboard.press('Enter');
+ await expect(video.locator('iframe')).toBeFocused();
+ });
+});
diff --git a/e2e/tests/management/homepage-preview-messages.spec.ts b/e2e/tests/management/homepage-preview-messages.spec.ts
new file mode 100644
index 0000000000..1977e33238
--- /dev/null
+++ b/e2e/tests/management/homepage-preview-messages.spec.ts
@@ -0,0 +1,180 @@
+import { type Frame, type Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createDraftEventWithTicket } from '../../api/factory';
+import { DesignerPage } from '../../pages/designer.page';
+import { waitForHydration } from '../../utils/hydration';
+import { uniqueName } from '../../utils/unique';
+
+const FOREIGN_PAGE_URL = 'http://localhost:59174/';
+const INJECTED_HEADING = 'Injected preview heading';
+
+type ProbeWindow = Window & {
+ injectionSeen?: boolean;
+ previewScriptRan?: boolean;
+ previewTarget?: Window | null;
+};
+
+const injectedSettings = {
+ homepage_sections: [
+ {
+ id: 'injected-text',
+ type: 'TEXT',
+ enabled: true,
+ heading: INJECTED_HEADING,
+ body: '
',
+ },
+ ],
+};
+
+const watchForInjection = (page: Page) => page.context().addInitScript((heading) => {
+ const probe = window as ProbeWindow;
+ new MutationObserver(() => {
+ if (document.body?.textContent?.includes(heading)) {
+ probe.injectionSeen = true;
+ }
+ }).observe(document, { subtree: true, childList: true, characterData: true });
+}, INJECTED_HEADING);
+
+const injectionReached = async (target: Page | Frame): Promise => {
+ try {
+ return await target.evaluate(() => {
+ const probe = window as ProbeWindow;
+ return probe.injectionSeen === true || probe.previewScriptRan === true;
+ });
+ } catch {
+ return false;
+ }
+};
+
+const openForeignPage = async (page: Page, body = ''): Promise => {
+ const html = `Foreign page Foreign page
${body}`;
+ await page.context().grantPermissions(['local-network-access'], { origin: new URL(FOREIGN_PAGE_URL).origin });
+ await page.route(`${FOREIGN_PAGE_URL}**`, (route) => {
+ if (route.request().url().split('?')[0] === FOREIGN_PAGE_URL) {
+ return route.fulfill({ contentType: 'text/html', body: html });
+ }
+ return route.fulfill({ status: 404, body: '' });
+ });
+ await page.goto(FOREIGN_PAGE_URL);
+};
+
+const openPreviewPopup = async (opener: Page, previewUrl: string): Promise => {
+ const [popup] = await Promise.all([
+ opener.context().waitForEvent('page'),
+ opener.evaluate((url) => {
+ (window as ProbeWindow).previewTarget = window.open(url, 'hi-preview');
+ }, previewUrl),
+ ]);
+ await popup.waitForLoadState();
+ await waitForHydration(popup, 'h1');
+ return popup;
+};
+
+const postInjectedSettings = (sender: Page, type: string, durationMs = 2500) =>
+ sender.evaluate(async ({ messageType, settings, duration }) => {
+ const target = (window as ProbeWindow).previewTarget!;
+ const deadline = Date.now() + duration;
+ while (Date.now() < deadline) {
+ target.postMessage({ type: messageType, settings }, '*');
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+ }, { messageType: type, settings: injectedSettings, duration: durationMs });
+
+test.describe('homepage preview messages', () => {
+ test('the event preview ignores settings posted by a foreign page that opened it', async ({ authedPage: page, api, account, baseURL }) => {
+ const event = await createDraftEventWithTicket(api, account.organizerId);
+ await watchForInjection(page);
+ await openForeignPage(page);
+
+ const preview = await openPreviewPopup(page, `${baseURL}/event/${event.eventId}/preview`);
+ await expect(preview.getByRole('heading', { name: event.title })).toBeVisible();
+
+ await postInjectedSettings(page, 'UPDATE_SETTINGS');
+
+ expect(await injectionReached(preview)).toBe(false);
+ await expect(preview.getByText(INJECTED_HEADING)).toHaveCount(0);
+ await expect(preview.getByRole('heading', { name: event.title })).toBeVisible();
+ });
+
+ test('the event preview ignores settings from an app window that is not its parent frame', async ({ authedPage: page, api, account, baseURL }) => {
+ const event = await createDraftEventWithTicket(api, account.organizerId);
+ const previewUrl = `${baseURL}/event/${event.eventId}/preview`;
+ await watchForInjection(page);
+ await page.goto(previewUrl);
+ await waitForHydration(page, 'h1');
+
+ const preview = await openPreviewPopup(page, previewUrl);
+ await expect(preview.getByRole('heading', { name: event.title })).toBeVisible();
+
+ await postInjectedSettings(page, 'UPDATE_SETTINGS');
+
+ expect(await injectionReached(preview)).toBe(false);
+ await expect(preview.getByText(INJECTED_HEADING)).toHaveCount(0);
+ });
+
+ test('a foreign page cannot frame the event preview and drive it', async ({ authedPage: page, api, account, baseURL }) => {
+ const event = await createDraftEventWithTicket(api, account.organizerId);
+ await watchForInjection(page);
+ await openForeignPage(page, ``);
+ await page.evaluate(() => {
+ (window as ProbeWindow).previewTarget = (document.getElementById('preview') as HTMLIFrameElement).contentWindow;
+ });
+
+ await postInjectedSettings(page, 'UPDATE_SETTINGS', 8000);
+
+ const frame = (await (await page.locator('#preview').elementHandle())!.contentFrame())!;
+ expect(await injectionReached(frame)).toBe(false);
+ await expect(frame.getByText(INJECTED_HEADING)).toHaveCount(0);
+ });
+
+ test('the organizer preview ignores settings posted by a foreign page that opened it', async ({ authedPage: page, account, baseURL }) => {
+ await watchForInjection(page);
+ await openForeignPage(page);
+
+ const preview = await openPreviewPopup(page, `${baseURL}/organizer/${account.organizerId}/preview`);
+ await postInjectedSettings(page, 'UPDATE_ORGANIZER_SETTINGS');
+
+ expect(await injectionReached(preview)).toBe(false);
+ await expect(preview.getByText(INJECTED_HEADING)).toHaveCount(0);
+ });
+
+ test('the homepage designers still drive their own previews', async ({ authedPage: page, api, account }) => {
+ const event = await createDraftEventWithTicket(api, account.organizerId);
+ const designer = new DesignerPage(page);
+
+ await designer.gotoHomepageDesigner(event.eventId);
+ const eventHeading = uniqueName('Event preview heading');
+ const eventText = await designer.addSection('text');
+ await eventText.getByLabel('Heading').fill(eventHeading);
+ await eventText.locator('[contenteditable="true"]').fill('Doors open at seven.');
+ await expect(designer.eventPreview.getByTestId('homepage-section-text')).toContainText(eventHeading);
+
+ await designer.gotoOrganizerHomepageDesigner(account.organizerId);
+ const organizerHeading = uniqueName('Organizer preview heading');
+ const organizerText = await designer.addSection('text');
+ await organizerText.getByLabel('Heading').fill(organizerHeading);
+ await organizerText.locator('[contenteditable="true"]').fill('Meet the team behind the festival.');
+ await expect(page.frameLocator('iframe[title="Organizer Homepage Preview"]').getByTestId('homepage-section-text'))
+ .toContainText(organizerHeading);
+ });
+
+ test('the preview routes can only be framed by the app itself', async ({ request, api, account }) => {
+ const event = await createDraftEventWithTicket(api, account.organizerId);
+
+ for (const path of [
+ `/event/${event.eventId}/preview`,
+ `/organizer/${account.organizerId}/preview`,
+ `/event/${event.eventId}/preview//`,
+ `/organizer/${account.organizerId}/preview//`,
+ `/Event/${event.eventId}/Preview`,
+ `/event/${event.eventId}/%70review`,
+ '/Manage/events',
+ '/%6Danage/events',
+ '/AUTH/LOGIN',
+ ]) {
+ const response = await request.get(path);
+ expect(response.headers()['content-security-policy'], path).toBe("frame-ancestors 'self'");
+ expect(response.headers()['x-frame-options'], path).toBe('SAMEORIGIN');
+ }
+ });
+});
diff --git a/e2e/tests/management/homepage-sections-editor-guards.spec.ts b/e2e/tests/management/homepage-sections-editor-guards.spec.ts
new file mode 100644
index 0000000000..50aa3abf32
--- /dev/null
+++ b/e2e/tests/management/homepage-sections-editor-guards.spec.ts
@@ -0,0 +1,102 @@
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket } from '../../api/factory';
+import { DesignerPage } from '../../pages/designer.page';
+import { PublicEventPage } from '../../pages/public-event.page';
+import { invitedMember } from '../../utils/team';
+
+test.describe('homepage section editor guards', () => {
+ test('drag handles are named and keep their instructions after hydration', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const designer = new DesignerPage(page);
+ await designer.gotoHomepageDesigner(event.eventId);
+
+ await expect(designer.sectionRow('tickets').getByRole('button', { name: 'Drag to reorder Tickets' })).toBeVisible();
+ await expect(designer.sectionRow('about').getByRole('button', { name: 'Drag to reorder About' })).toBeVisible();
+
+ const describedBy = await page.evaluate(() =>
+ Array.from(document.querySelectorAll('[data-testid="homepage-sections-list"] [aria-describedby]')).map((element) => {
+ const id = element.getAttribute('aria-describedby') ?? '';
+ return { id, exists: !!document.getElementById(id) };
+ }),
+ );
+ expect(describedBy.length).toBeGreaterThan(0);
+ expect(describedBy.filter((entry) => !entry.exists)).toEqual([]);
+ });
+
+ test('server limits are flagged on the field before saving', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const designer = new DesignerPage(page);
+ await designer.gotoHomepageDesigner(event.eventId);
+
+ const faq = await designer.addSection('faq');
+ await faq.getByTestId('homepage-faq-add').click();
+ await faq.getByLabel('Question 1').fill('Is there parking?');
+ await faq.getByLabel('Answer 1').fill('a'.repeat(5001));
+ await expect(faq.getByText('This answer is too long. Shorten it before saving.')).toBeVisible();
+ await faq.getByLabel('Answer 1').fill('Yes, free parking opens two hours before doors.');
+ await expect(faq.getByText('This answer is too long. Shorten it before saving.')).toHaveCount(0);
+
+ const video = await designer.addSection('video');
+ await video.getByTestId('homepage-video-url').fill('http://www.youtube.com/watch?v=dQw4w9WgXcQ');
+ await expect(video.getByTestId('homepage-video-url')).toHaveValue('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
+ await expect(video).toContainText('YouTube video found');
+
+ await designer.saveButton.click();
+ await expect(designer.savedStatus).toBeVisible();
+
+ const settings = await api.getEventSettings(event.eventId);
+ const sections = settings.homepage_sections as { type: string; video_url?: string }[];
+ expect(sections.find((section) => section.type === 'VIDEO')?.video_url).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
+ });
+
+ test('an FAQ without an answer is a plain row, not an expandable one', async ({ page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ await api.updateEventSettings(event.eventId, {
+ homepage_sections: [
+ { id: 'tickets', type: 'TICKETS', enabled: true },
+ {
+ id: 'faqs',
+ type: 'FAQ',
+ enabled: true,
+ faqs: [
+ { question: 'Is there parking?', answer: 'Yes, free parking opens two hours before doors.
' },
+ { question: 'Doors open at 7pm', answer: null },
+ ],
+ },
+ ],
+ });
+
+ await new PublicEventPage(page).goto(event.eventId, event.slug);
+
+ const faq = page.getByTestId('homepage-section-faq');
+ await expect(faq.getByText('Doors open at 7pm')).toBeVisible();
+ await expect(faq.locator('details')).toHaveCount(1);
+ await expect(faq.locator('details summary')).toHaveText('Is there parking?');
+ await expect(faq.locator('details', { hasText: 'Doors open at 7pm' })).toHaveCount(0);
+ });
+
+ test('an event-only member is not linked to organizer settings they cannot open', async ({ freshAccount, mailpit, publicApi, browser }) => {
+ const event = await createLiveEventWithFreeTicket(freshAccount.api, freshAccount.organizerId);
+ await freshAccount.api.updateOrganizerStatus(freshAccount.organizerId, 'DRAFT');
+
+ const ownerPage = await freshAccount.newAuthedPage();
+ const ownerDesigner = new DesignerPage(ownerPage);
+ await ownerDesigner.gotoHomepageDesigner(event.eventId);
+ const ownerOrganizerRow = ownerDesigner.sectionRow('organizer');
+ await ownerOrganizerRow.getByRole('button', { name: 'Section settings' }).click();
+ await expect(ownerOrganizerRow).toContainText("Your organizer profile isn't published.");
+ await expect(ownerOrganizerRow.getByRole('link', { name: 'Organizer settings' })).toBeVisible();
+
+ const { page } = await invitedMember(freshAccount, mailpit, publicApi, browser, {
+ role: 'ORGANIZER',
+ access_scope: 'RESTRICTED',
+ event_ids: [event.eventId],
+ });
+ const designer = new DesignerPage(page);
+ await designer.gotoHomepageDesigner(event.eventId);
+ const organizerRow = designer.sectionRow('organizer');
+ await organizerRow.getByRole('button', { name: 'Section settings' }).click();
+ await expect(organizerRow).toContainText("Your organizer profile isn't published.");
+ await expect(organizerRow.getByRole('link', { name: 'Organizer settings' })).toHaveCount(0);
+ });
+});
diff --git a/e2e/tests/management/lineup-import-export.spec.ts b/e2e/tests/management/lineup-import-export.spec.ts
index 3e5e15588f..997760d898 100644
--- a/e2e/tests/management/lineup-import-export.spec.ts
+++ b/e2e/tests/management/lineup-import-export.spec.ts
@@ -5,6 +5,7 @@ import { createCompletedOrder, createLiveEventWithFreeTicket } from '../../api/f
import { LineupPage } from '../../pages/lineup.page';
import { PublicEventPage } from '../../pages/public-event.page';
import { uniqueEmail, uniqueName } from '../../utils/unique';
+import { waitForHydration } from '../../utils/hydration';
const EXCEL_FIXTURE = fileURLToPath(new URL('../../fixtures/assets/lineup-excel.xlsx', import.meta.url));
@@ -175,8 +176,8 @@ test.describe('lineup import and export', () => {
await lineup.goto(event.eventId);
await lineup.openView('Timetable');
- await expect(page.getByRole('tablist', { name: 'Days' }).getByRole('tab')).toHaveCount(1);
- await expect(page.getByRole('tablist', { name: 'Days' }).getByRole('tab')).toContainText('Jun 14');
+ await expect(page.getByRole('group', { name: 'Days' }).getByRole('button')).toHaveCount(1);
+ await expect(page.getByRole('group', { name: 'Days' }).getByRole('button')).toContainText('Jun 14');
const block = page.getByText(headliner, { exact: true }).locator('xpath=ancestor::*[contains(@class, "calendarEventText")]');
await expect(block).toHaveCount(1);
await expect(block).toContainText('11:00 PM–1:00 AM');
@@ -233,7 +234,7 @@ test.describe('lineup for visitors', () => {
await session.getByTestId('session-add-to-calendar').click();
const [ics] = await Promise.all([
page.waitForEvent('download'),
- page.getByRole('button', { name: 'Download .ics' }).click(),
+ page.getByRole('menuitem', { name: 'Download .ics' }).click(),
]);
const calendar = await readFile(await ics.path(), 'utf8');
expect(calendar).toContain('DTSTART:20300614T213000Z');
@@ -271,6 +272,39 @@ test.describe('lineup for visitors', () => {
}
});
+ test('the lineup widget follows the Lineup section toggle, and a missing event shows its message', async ({ page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const headliner = uniqueName('Late Bill');
+ await api.importLineup(event.eventId, {
+ people: [{ name: headliner, billing_tier: 'HEADLINER' }],
+ slots: [{ starts_at: '2030-06-14 21:30:00', ends_at: '2030-06-14 23:00:00', people: [headliner] }],
+ });
+ await api.updateEventSettings(event.eventId, {
+ homepage_sections: [{ id: 'lineup', type: 'LINEUP', enabled: false }],
+ });
+
+ await page.goto(`/widget/${event.eventId}/lineup`);
+ await expect(page.getByTestId('lineup-widget')).toContainText('Coming soon');
+ await expect(page.getByTestId('lineup-widget')).not.toContainText(headliner);
+ await page.goto(`/widget/${event.eventId}/schedule`);
+ await expect(page.getByTestId('schedule-widget')).toContainText(headliner);
+
+ const errors: string[] = [];
+ page.on('pageerror', error => errors.push(error.message));
+ page.on('console', message => {
+ if (message.type() === 'error' && /hydrat|#418|#423|#425/i.test(message.text())) {
+ errors.push(message.text());
+ }
+ });
+ for (const variant of ['lineup', 'schedule']) {
+ await page.goto(`/widget/999999999/${variant}`);
+ await waitForHydration(page, 'main.hi-widget-tokens');
+ await expect(page.getByText('This event is not available')).toBeVisible();
+ await expect(page.locator('.hi-widget-loading')).toHaveCount(0);
+ }
+ expect(errors).toEqual([]);
+ });
+
test('turning the lineup sections off hides them from the widget, search data and emails', async ({ page, api, account, publicApi, mailpit }) => {
const event = await createLiveEventWithFreeTicket(api, account.organizerId);
const headliner = uniqueName('Secret Set');
diff --git a/e2e/tests/management/lineup-manage.spec.ts b/e2e/tests/management/lineup-manage.spec.ts
new file mode 100644
index 0000000000..7ed85274bf
--- /dev/null
+++ b/e2e/tests/management/lineup-manage.spec.ts
@@ -0,0 +1,220 @@
+import { writeFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import type { Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket, createRecurringLiveEvent } from '../../api/factory';
+import { LineupPage } from '../../pages/lineup.page';
+import { PeoplePage } from '../../pages/people.page';
+import { pickDate } from '../../utils/datePicker';
+import { uniqueName } from '../../utils/unique';
+
+const COVER_PHOTO = fileURLToPath(new URL('../../fixtures/assets/event-cover.png', import.meta.url));
+const GALLERY_PHOTO = fileURLToPath(new URL('../../fixtures/assets/gallery-photo.png', import.meta.url));
+
+const addDays = (day: string, days: number): string => {
+ const date = new Date(`${day}T00:00:00Z`);
+ date.setUTCDate(date.getUTCDate() + days);
+ return date.toISOString().slice(0, 10);
+};
+
+const chipLabel = (day: string): string =>
+ new Date(`${day}T12:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
+
+const utcStamp = (date: Date): string => date.toISOString().slice(0, 16).replace('T', ' ') + ':00';
+
+const dayChips = (page: Page) => page.getByRole('group', { name: 'Days' }).getByRole('button');
+
+const selectedDay = (page: Page) => page.getByRole('group', { name: 'Days' }).getByRole('button', { pressed: true });
+
+const currentTimeIndicator = (page: Page) => page.locator('.mantine-ResourcesDayView-resourcesDayViewCurrentTimeIndicator');
+
+const addTimeSlot = async (page: Page, details: { person: string; date: string; starts: string; ends: string }) => {
+ await page.getByTestId('lineup-add-slot-button').click();
+ const dialog = page.getByRole('dialog');
+ await page.getByTestId('lineup-slot-people').click();
+ await page.getByRole('option', { name: details.person }).click();
+ await pickDate(page, dialog.getByRole('button', { name: /^Date/ }), details.date);
+ await dialog.getByLabel('Starts').fill(details.starts);
+ await dialog.getByLabel('Ends').fill(details.ends);
+ await page.getByTestId('lineup-slot-save-button').click();
+};
+
+test.describe('managing a lineup', () => {
+ test('a recurring event flags slots on days it does not run instead of counting them as scheduled', async ({ authedPage: page, api, account }) => {
+ const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 });
+ const firstDate = event.occurrences[0].start_date.slice(0, 10);
+ const secondDate = event.occurrences[1].start_date.slice(0, 10);
+ const offDay = addDays(firstDate, 2);
+ const name = uniqueName('Wrong Night');
+ await api.importLineup(event.eventId, {
+ people: [{ name, billing_tier: 'HEADLINER' }],
+ slots: [{ starts_at: `${offDay} 20:00:00`, ends_at: `${offDay} 21:00:00`, people: [name] }],
+ });
+
+ const lineup = new LineupPage(page);
+ await lineup.goto(event.eventId);
+
+ const stats = page.getByTestId('lineup-stats');
+ await expect(stats).toContainText('0/1');
+ await expect(stats).toContainText('1 not on an event date');
+ await expect(stats).not.toContainText('Everyone has a time');
+
+ await lineup.openView('Timetable');
+ await expect(selectedDay(page)).toContainText(chipLabel(offDay));
+ await expect(selectedDay(page)).toContainText('No event date on this day');
+ await expect(page.getByText("Your event has no date on this day, so slots here won't show on your event page.")).toBeVisible();
+
+ await lineup.stepTimetable('Next');
+ await expect(selectedDay(page)).toContainText(chipLabel(secondDate));
+ await expect(selectedDay(page)).not.toContainText('No event date on this day');
+ await expect(page.getByText("Your event has no date on this day, so slots here won't show on your event page.")).toHaveCount(0);
+
+ await lineup.stepTimetable('Previous');
+ await expect(selectedDay(page)).toContainText(chipLabel(firstDate));
+
+ await lineup.openView('List');
+ await page.getByTestId('lineup-agenda').getByRole('button', { name: new RegExp(name) }).click();
+ await expect(page.getByRole('dialog')).toContainText("Your event has no date on this day, so this slot won't show on your event page.");
+ await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
+
+ await lineup.openImport();
+ await lineup.pasteRows([
+ ['Date', 'Start', 'End', 'Title'],
+ [offDay, '19:00', '20:00', 'Wrong night talk'],
+ [secondDate, '19:00', '20:00', 'Right night talk'],
+ ]);
+ await expect(lineup.importRow('Wrong night talk')).toContainText("No event date on this day, so it won't show on your event page");
+ await expect(lineup.importRow('Right night talk')).not.toContainText('No event date on this day');
+ });
+
+ test('the timetable only shows the current time on today', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const name = uniqueName('Right Now');
+ const now = new Date(Math.floor(Date.now() / 60_000) * 60_000);
+ await api.importLineup(event.eventId, {
+ people: [{ name }],
+ slots: [{ starts_at: utcStamp(now), ends_at: utcStamp(new Date(now.getTime() + 60 * 60_000)), people: [name] }],
+ });
+ await page.clock.setFixedTime(now);
+
+ const lineup = new LineupPage(page);
+ await lineup.goto(event.eventId);
+ await lineup.openView('Timetable');
+ await expect(currentTimeIndicator(page)).toHaveCount(1);
+ const today = (await selectedDay(page).textContent()) ?? '';
+
+ await lineup.stepTimetable('Next');
+ await expect(selectedDay(page)).not.toHaveText(today);
+ await expect(currentTimeIndicator(page)).toHaveCount(0);
+ });
+
+ test('a slot added after midnight stays on the evening it belongs to', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const opener = uniqueName('Opener');
+ const closer = uniqueName('Closer');
+ await api.importLineup(event.eventId, {
+ people: [{ name: opener }, { name: closer }],
+ slots: [{ starts_at: '2030-06-14 22:00:00', ends_at: '2030-06-14 23:30:00', people: [opener] }],
+ });
+
+ const lineup = new LineupPage(page);
+ await lineup.goto(event.eventId);
+ await lineup.openView('Timetable');
+ await expect(dayChips(page)).toHaveCount(1);
+ await expect(selectedDay(page)).toContainText('Jun 14');
+
+ await addTimeSlot(page, { person: closer, date: '2030-06-15', starts: '01:00', ends: '02:00' });
+ await expect(page.getByText('Time slot added')).toBeVisible();
+
+ await expect(selectedDay(page)).toContainText('Jun 14');
+ await expect(selectedDay(page)).toContainText('2 slots');
+ const block = page.getByText(closer, { exact: true }).locator('xpath=ancestor::*[contains(@class, "calendarEventText")]');
+ await expect(block).toContainText('1:00 AM–2:00 AM');
+ });
+
+ test('profile photos can be reordered and removed from the keyboard', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const name = uniqueName('Photo Ready');
+ const profile = await api.createLineupProfile(account.organizerId, { name });
+ await api.addLineupEntry(event.eventId, { profile_id: profile.id, billing_tier: 'HEADLINER' });
+
+ const lineup = new LineupPage(page);
+ await lineup.goto(event.eventId);
+ await lineup.person(name).getByRole('button', { name: new RegExp(name) }).filter({ hasText: name }).click();
+ await lineup.uploadPhoto(COVER_PHOTO);
+ await expect(lineup.drawer().getByRole('button', { name: 'Remove photo 1' })).toBeVisible();
+ await lineup.uploadPhoto(GALLERY_PHOTO);
+ await expect(lineup.drawer().getByRole('button', { name: 'Move photo 2 of 2' })).toBeVisible();
+
+ const photos = lineup.drawer().getByTestId('lineup-profile-photos').locator('[data-testid^="lineup-profile-photo-"]:not([data-testid$="-dropzone"])');
+ await expect(photos).toHaveCount(2);
+ const firstTile = photos.nth(0);
+ const secondId = await photos.nth(1).getAttribute('data-testid');
+ await expect(firstTile).not.toHaveAttribute('role', 'button');
+
+ const sortRequest = page.waitForRequest(request => /\/profiles\/\d+\/images\/sort/.test(request.url()));
+ await lineup.drawer().getByRole('button', { name: 'Move photo 2 of 2' }).focus();
+ await page.keyboard.press('Space');
+ await expect(photos.nth(1)).toHaveAttribute('data-dragging', 'true');
+ await expect(async () => {
+ await page.keyboard.press('ArrowLeft');
+ await expect(photos.nth(0)).toHaveAttribute('style', /translate3d\([1-9]/, { timeout: 1000 });
+ }).toPass();
+ await page.keyboard.press('Space');
+ await sortRequest;
+ await expect(photos.nth(0)).toHaveAttribute('data-testid', secondId!);
+ await expect(photos.nth(0)).toContainText('Main photo');
+
+ await lineup.drawer().getByRole('button', { name: 'Remove photo 2' }).focus();
+ await page.keyboard.press('Enter');
+ const confirm = page.getByRole('dialog').filter({ hasText: 'Remove this photo?' });
+ await expect(confirm).toBeVisible();
+ const deleteRequest = page.waitForRequest(request => request.method() === 'DELETE' && /\/images\/\d+/.test(request.url()));
+ await confirm.getByRole('button', { name: 'Remove' }).click();
+ await deleteRequest;
+ await expect(photos).toHaveCount(1);
+ await expect(photos.nth(0)).toHaveAttribute('data-testid', secondId!);
+ });
+
+ test('people hidden from every lineup are not counted as showing on a live event', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const announced = uniqueName('Announced Act');
+ const secret = uniqueName('Secret Act');
+ const announcedProfile = await api.createLineupProfile(account.organizerId, { name: announced });
+ const secretProfile = await api.createLineupProfile(account.organizerId, { name: secret });
+ await api.addLineupEntry(event.eventId, { profile_id: announcedProfile.id, billing_tier: 'FEATURED' });
+ await api.addLineupEntry(event.eventId, { profile_id: secretProfile.id, billing_tier: 'HEADLINER', is_hidden: true });
+
+ const people = new PeoplePage(page);
+ await people.goto(account.organizerId);
+ await expect(people.row(secret)).toContainText(event.title);
+ await page.getByRole('button', { name: /On live events/ }).click();
+
+ await expect(people.row(announced)).toBeVisible();
+ await expect(people.row(secret)).toHaveCount(0);
+ });
+
+ test('the import preview takes focus and keeps comma names as one person', async ({ authedPage: page, api, account }, testInfo) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const csvPath = testInfo.outputPath('schedule.csv');
+ await writeFile(csvPath, [
+ 'Date,Start,End,Title,People',
+ '2030-06-14,19:00,20:00,,"Alice, Bob"',
+ '2030-06-14,20:00,21:00,Panel,Carol; Dan',
+ ].join('\n'));
+
+ const lineup = new LineupPage(page);
+ await lineup.goto(event.eventId);
+
+ await lineup.openImport();
+ await expect(lineup.importDialog().getByRole('button', { name: 'Choose a spreadsheet to import' })).toBeVisible();
+ await lineup.importDialog().locator('input[type="file"]').setInputFiles(csvPath);
+
+ await expect(lineup.importDialog().getByTestId('lineup-import-summary')).toBeFocused();
+ await expect(lineup.importDialog().getByTestId('lineup-import-summary')).toHaveAccessibleName(/people to add.*time slots to add.*rows to fix/);
+ await expect(lineup.importRow('Alice, Bob')).toContainText('Treated as one person. Separate several people with a semicolon.');
+ await expect(lineup.importRow('Panel')).toContainText('Carol; Dan');
+ await expect(lineup.importRow('Panel')).toContainText('Creates new profiles for Carol; Dan');
+ await expect(lineup.importRow('Panel')).not.toContainText('Treated as one person');
+ });
+});
diff --git a/e2e/tests/management/messages.spec.ts b/e2e/tests/management/messages.spec.ts
index edebca45c2..a7dd03c098 100644
--- a/e2e/tests/management/messages.spec.ts
+++ b/e2e/tests/management/messages.spec.ts
@@ -1,8 +1,22 @@
+import type { Page, Request } from '@playwright/test';
import { test, expect } from '../../fixtures';
import { MessagePage } from '../../pages/message.page';
-import { createDraftEvent } from '../../api/factory';
+import { createDraftEvent, createDraftEventWithTicket } from '../../api/factory';
import { uniqueName } from '../../utils/unique';
+const isSendMessageRequest = (request: Request): boolean =>
+ request.method() === 'POST' && /\/events\/\d+\/messages$/.test(new URL(request.url()).pathname);
+
+const fillSubjectAndBody = async (page: Page, subject: string, body: string): Promise => {
+ await page.getByLabel(/^Subject/).fill(subject);
+ const editor = page.locator('.ProseMirror').first();
+ await editor.click();
+ await editor.fill(body);
+};
+
+const confirmTransactional = (page: Page) =>
+ page.getByRole('checkbox', { name: /I confirm this is a transactional message/ }).check();
+
test.describe('messages', () => {
test('an organizer sends a message to all attendees and sees it in the list', { tag: '@smoke' }, async ({ authedPage, api, account }) => {
@@ -15,4 +29,88 @@ test.describe('messages', () => {
await expect(authedPage.getByText(subject).first()).toBeVisible();
});
+
+ test('composing without choosing recipients shows an error under Recipients and sends nothing', async ({ authedPage, api, account }) => {
+ const event = await createDraftEvent(api, account.organizerId);
+ const sendRequests: Request[] = [];
+ authedPage.on('request', (request) => {
+ if (isSendMessageRequest(request)) {
+ sendRequests.push(request);
+ }
+ });
+
+ const messages = new MessagePage(authedPage);
+ await messages.goto(event.eventId);
+ await authedPage.getByTestId('message-compose-button').click();
+ await authedPage.getByRole('heading', { name: 'Send a message' }).waitFor();
+
+ await fillSubjectAndBody(authedPage, uniqueName('No recipients'), 'Who is this for?');
+ await confirmTransactional(authedPage);
+ await authedPage.getByTestId('message-send-button').click();
+
+ await expect(authedPage.getByText('Choose who should receive this message')).toBeVisible();
+ await expect(authedPage.getByRole('heading', { name: 'Send a message' })).toBeVisible();
+ expect(sendRequests).toHaveLength(0);
+ });
+
+ test("messaging attendees from a ticket's menu keeps that ticket selected and sends", async ({ authedPage, api, account }) => {
+ const event = await createDraftEventWithTicket(api, account.organizerId, { productTitle: uniqueName('Early Bird') });
+
+ await authedPage.goto(`/manage/event/${event.eventId}/products`);
+ await authedPage.waitForLoadState('networkidle');
+ await authedPage.getByTestId('product-manage-button').first().click();
+ await authedPage.getByRole('menuitem', { name: 'Message Attendees' }).click();
+ await authedPage.getByRole('heading', { name: 'Send a message' }).waitFor();
+
+ await expect(authedPage.locator('.mantine-MultiSelect-pill').filter({ hasText: event.productTitle })).toBeVisible();
+
+ await fillSubjectAndBody(authedPage, uniqueName('Ticket holders'), 'A note for this ticket.');
+ await confirmTransactional(authedPage);
+
+ const sent = authedPage.waitForResponse((response) => isSendMessageRequest(response.request()));
+ await authedPage.getByTestId('message-send-button').click();
+ const response = await sent;
+
+ expect(response.ok()).toBe(true);
+ const body = response.request().postDataJSON();
+ expect(body.message_type).toBe('TICKET_HOLDERS');
+ expect(body.product_ids.map(Number)).toEqual([event.productId]);
+ });
+
+ test('a test sent while Schedule for later is selected goes to the sender now and schedules nothing', async ({ authedPage, api, account, mailpit }) => {
+ const event = await createDraftEvent(api, account.organizerId);
+ const subject = uniqueName('Draft reminder');
+
+ const messages = new MessagePage(authedPage);
+ await messages.goto(event.eventId);
+ await authedPage.getByTestId('message-compose-button').click();
+ await authedPage.getByRole('heading', { name: 'Send a message' }).waitFor();
+ await authedPage.getByRole('combobox', { name: 'Recipients' }).click();
+ await authedPage.getByRole('option', { name: 'All attendees of this event' }).click();
+ await fillSubjectAndBody(authedPage, subject, 'Checking how this looks.');
+
+ await authedPage.getByRole('button', { name: 'Schedule for later' }).click();
+ await authedPage.getByRole('button', { name: '1 day before event' }).click();
+ await confirmTransactional(authedPage);
+ await expect(authedPage.getByTestId('message-send-button')).toHaveText('Schedule Message');
+
+ await authedPage.getByTestId('message-send-options-button').click();
+ await authedPage.getByRole('menuitem', { name: 'Send as test' }).click();
+ await expect(authedPage.getByTestId('message-send-button')).toHaveText('Send Test');
+
+ const sent = authedPage.waitForResponse((response) => isSendMessageRequest(response.request()));
+ await authedPage.getByTestId('message-send-button').click();
+ const response = await sent;
+
+ expect(response.ok()).toBe(true);
+ const body = response.request().postDataJSON();
+ expect(body.is_test).toBe(true);
+ expect(body.scheduled_at).toBeUndefined();
+ expect((await response.json()).data.scheduled_at).toBeNull();
+
+ await expect(authedPage.getByText('Test email sent to your inbox')).toBeVisible();
+ await expect(messages.listItem(subject)).toBeVisible();
+ await expect(messages.listItem(subject).getByText('SCHEDULED', { exact: true })).toHaveCount(0);
+ await mailpit.waitForMessage(account.email, { subjectContains: subject });
+ });
});
diff --git a/e2e/tests/management/offline-payment-instructions-access.spec.ts b/e2e/tests/management/offline-payment-instructions-access.spec.ts
new file mode 100644
index 0000000000..4bd51d606b
--- /dev/null
+++ b/e2e/tests/management/offline-payment-instructions-access.spec.ts
@@ -0,0 +1,34 @@
+import { test, expect } from '../../fixtures';
+import { createDraftEvent } from '../../api/factory';
+import { EventSettingsPage } from '../../pages/event-settings.page';
+import { invitedMember } from '../../utils/team';
+import { uniqueName } from '../../utils/unique';
+
+test.describe('offline payment instructions access', () => {
+ test('an organizer without payment access sees the instructions read-only and can still save payment settings', async ({ freshAccount, mailpit, publicApi, browser }) => {
+ const event = await createDraftEvent(freshAccount.api, freshAccount.organizerId);
+ const instructions = `${uniqueName('Transfer to IBAN IE00 OWNER')}
`;
+ await freshAccount.api.updateEventSettings(event.eventId, {
+ payment_providers: ['STRIPE', 'OFFLINE'],
+ offline_payment_instructions: instructions,
+ });
+
+ const { page } = await invitedMember(freshAccount, mailpit, publicApi, browser, { role: 'ORGANIZER' });
+ const settings = new EventSettingsPage(page);
+ await settings.goto(event.eventId);
+
+ const payment = settings.section('payment-settings');
+ await expect(payment.getByText(instructions.replace(/<\/?p>/g, ''))).toBeVisible();
+ await expect(payment.getByText('Only members who can manage payments can change these instructions.')).toBeVisible();
+ await expect(settings.offlineInstructionsEditor).toHaveCount(0);
+
+ await payment.getByLabel('Require Billing Address').uncheck({ force: true });
+ await settings.saveSection('payment-settings');
+ await expect(page.getByText('Successfully Updated Payment & Invoicing Settings')).toBeVisible();
+
+ await expect.poll(async () => (await freshAccount.api.getEventSettings(event.eventId)).require_billing_address).toBe(false);
+ const saved = await freshAccount.api.getEventSettings(event.eventId);
+ expect(saved.offline_payment_instructions).toBe(instructions);
+ expect(saved.payment_providers).toEqual(['STRIPE', 'OFFLINE']);
+ });
+});
diff --git a/e2e/tests/management/organizer-week-start-modals.spec.ts b/e2e/tests/management/organizer-week-start-modals.spec.ts
new file mode 100644
index 0000000000..b91e372f34
--- /dev/null
+++ b/e2e/tests/management/organizer-week-start-modals.spec.ts
@@ -0,0 +1,37 @@
+import { test, expect } from '../../fixtures';
+import { createDraftEvent } from '../../api/factory';
+import { EventCreatePage } from '../../pages/event-create.page';
+import { OrganizerPage } from '../../pages/organizer.page';
+import { pickerWeekdayHeaders } from '../../utils/datePicker';
+import { uniqueName } from '../../utils/unique';
+
+test.describe('organizer first day of the week on the events dashboard', () => {
+ test('the new event date picker starts on the chosen organizer\'s first day of the week', async ({ freshAccount }) => {
+ await freshAccount.api.updateOrganizerSettings(freshAccount.organizerId, { first_day_of_week: 0 });
+ await freshAccount.api.createOrganizer(uniqueName('E2E Monday Org'));
+ const page = await freshAccount.newAuthedPage();
+
+ const create = new EventCreatePage(page);
+ await create.gotoDashboard();
+ await create.openCreateModal();
+ await page.getByTestId('create-event-organizer-select').click();
+ await page.getByTestId(`create-event-organizer-select-option-${freshAccount.organizerId}`).click();
+ await create.chip('start').click();
+
+ await expect(page.getByTestId('create-event-start-chip-popover').locator('thead th').first()).toHaveText('Su');
+ });
+
+ test('the duplicate event date picker starts on the event organizer\'s first day of the week', async ({ freshAccount }) => {
+ await freshAccount.api.updateOrganizerSettings(freshAccount.organizerId, { first_day_of_week: 0 });
+ await freshAccount.api.createOrganizer(uniqueName('E2E Monday Org'));
+ const event = await createDraftEvent(freshAccount.api, freshAccount.organizerId);
+ const page = await freshAccount.newAuthedPage();
+
+ const organizer = new OrganizerPage(page);
+ await organizer.gotoEventsDashboard();
+ await organizer.openEventDuplicateModal(event.title);
+ await page.getByRole('dialog').getByLabel(/^Start Date/).click();
+
+ await expect(pickerWeekdayHeaders(page).first()).toHaveText('Su');
+ });
+});
diff --git a/e2e/tests/management/product-duplicate.spec.ts b/e2e/tests/management/product-duplicate.spec.ts
new file mode 100644
index 0000000000..330fab89a1
--- /dev/null
+++ b/e2e/tests/management/product-duplicate.spec.ts
@@ -0,0 +1,60 @@
+import { test, expect } from '../../fixtures';
+import type { ProductRecord } from '../../api/types';
+import { ProductEditPage } from '../../pages/product-edit.page';
+import { uniqueName } from '../../utils/unique';
+
+interface SaleWindow {
+ sale_start_date?: string | null;
+ sale_end_date?: string | null;
+}
+
+type ProductWithSaleWindows = Omit & SaleWindow & { prices?: (SaleWindow & { label?: string })[] };
+
+const saleWindows = (product: ProductWithSaleWindows) => ({
+ product: [product.sale_start_date, product.sale_end_date],
+ tiers: (product.prices ?? []).map((price) => [price.label, price.sale_start_date, price.sale_end_date]),
+});
+
+test.describe('product duplication', () => {
+ test('duplicating a tiered product on a New York event keeps every sale window', async ({ authedPage, api, account }) => {
+ const event = await api.createEvent({
+ title: uniqueName('E2E New York Event'),
+ type: 'SINGLE',
+ organizer_id: account.organizerId,
+ start_date: '2030-07-01T23:00:00Z',
+ category: 'MUSIC',
+ currency: 'USD',
+ timezone: 'America/New_York',
+ });
+ const categories = await api.listProductCategories(event.id);
+ const earlyBird = { price: 10, label: 'Early bird', sale_start_date: '2030-05-01 09:00:00', sale_end_date: '2030-05-15 17:30:00' };
+ const standard = { price: 20, label: 'Standard', sale_start_date: '2030-05-15 17:30:00', sale_end_date: '2030-06-30 22:00:00' };
+ const created = await api.createProduct(event.id, {
+ title: 'Festival Pass',
+ product_type: 'TICKET',
+ type: 'TIERED',
+ product_category_id: categories[0].id,
+ prices: [earlyBird, standard],
+ sale_start_date: '2030-04-20 10:00:00',
+ sale_end_date: '2030-06-30 23:00:00',
+ });
+ const original = await api.getProduct(event.id, created.id) as ProductWithSaleWindows;
+ expect(original.sale_start_date).toMatch(/^2030-04-20[T ]14:00:00/);
+ expect(original.prices?.[0].sale_start_date).toMatch(/^2030-05-01[T ]13:00:00/);
+
+ const products = new ProductEditPage(authedPage);
+ await products.goto(event.id);
+ await authedPage.getByTestId('product-manage-button').first().click();
+ await authedPage.getByRole('menuitem', { name: 'Duplicate' }).click();
+ await authedPage.getByRole('heading', { name: 'Duplicate Product' }).waitFor();
+ await expect(authedPage.getByLabel(/^Name/)).toHaveValue('Festival Pass (Copy)');
+ await authedPage.getByTestId('product-duplicate-submit-button').click();
+ await expect(authedPage.getByText('Successfully Duplicated Product')).toBeVisible();
+
+ const copySummary = (await api.listProducts(event.id)).find((product) => product.title === 'Festival Pass (Copy)');
+ expect(copySummary).toBeDefined();
+ const copy = await api.getProduct(event.id, copySummary!.id) as ProductWithSaleWindows;
+
+ expect(saleWindows(copy)).toEqual(saleWindows(original));
+ });
+});
diff --git a/e2e/tests/management/promo-codes.spec.ts b/e2e/tests/management/promo-codes.spec.ts
index e0bbf7548b..7cd7cff8b6 100644
--- a/e2e/tests/management/promo-codes.spec.ts
+++ b/e2e/tests/management/promo-codes.spec.ts
@@ -2,6 +2,7 @@ import { test, expect } from '../../fixtures';
import { PromoCodePage } from '../../pages/promo-code.page';
import { createDraftEvent } from '../../api/factory';
import { uniqueCode } from '../../utils/unique';
+import { expectDateTime } from '../../utils/datePicker';
test.describe('promo codes', () => {
test('an organizer creates a promo code and sees it in the list', { tag: '@smoke' }, async ({ authedPage, api, account }) => {
@@ -40,4 +41,18 @@ test.describe('promo codes', () => {
await expect(authedPage.getByLabel('Expiry Date')).toBeVisible();
await expect(authedPage.getByTestId('promo-code-advanced-toggle')).toHaveText('Hide advanced options');
});
+
+ test('an expiry picked as a date only lasts until the end of that day', async ({ authedPage, api, account }) => {
+ const event = await createDraftEvent(api, account.organizerId);
+ const code = uniqueCode('EOD');
+
+ const promoCodes = new PromoCodePage(authedPage);
+ await promoCodes.goto(event.eventId);
+ await promoCodes.createPromoCode(code, { expiryDay: '2030-12-24' });
+
+ await expect(authedPage.getByRole('row').filter({ hasText: code.toUpperCase() })).toBeVisible();
+ await promoCodes.openEditModal(code);
+
+ await expectDateTime(authedPage, authedPage.getByLabel('Expiry Date'), '2030-12-24T23:59');
+ });
});
diff --git a/e2e/tests/management/report-load-errors.spec.ts b/e2e/tests/management/report-load-errors.spec.ts
new file mode 100644
index 0000000000..870cf1db94
--- /dev/null
+++ b/e2e/tests/management/report-load-errors.spec.ts
@@ -0,0 +1,45 @@
+import type { Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket } from '../../api/factory';
+
+async function failGetRequests(page: Page, url: RegExp): Promise<() => void> {
+ let failing = true;
+ await page.route(url, (route) => (failing && route.request().method() === 'GET'
+ ? route.fulfill({ status: 500, json: { message: 'Server Error' } })
+ : route.continue()));
+ return () => {
+ failing = false;
+ };
+}
+
+test.describe('report load errors', () => {
+ test('an event report offers a retry instead of an empty state when loading fails', async ({ freshAccount }) => {
+ const event = await createLiveEventWithFreeTicket(freshAccount.api, freshAccount.organizerId);
+ const page = await freshAccount.newAuthedPage();
+ const recover = await failGetRequests(page, /\/api\/events\/\d+\/reports\/product_sales(\?.*)?$/);
+
+ await page.goto(`/manage/event/${event.eventId}/report/product_sales`);
+
+ await expect(page.getByText("Couldn't load this report")).toBeVisible({ timeout: 20_000 });
+ await expect(page.getByText('Nothing to show yet')).toHaveCount(0);
+
+ recover();
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await expect(page.getByRole('heading', { name: 'Product Sales Report' }).or(page.getByText('Nothing to show yet'))).toBeVisible();
+ await expect(page.getByText("Couldn't load this report")).toHaveCount(0);
+ });
+
+ test('an organizer report offers a retry instead of a no-data message when loading fails', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const recover = await failGetRequests(page, /\/api\/organizers\/\d+\/reports\/events_performance(\?.*)?$/);
+
+ await page.goto(`/manage/organizer/${freshAccount.organizerId}/report/events_performance`);
+
+ await expect(page.getByText("Couldn't load this report")).toBeVisible({ timeout: 20_000 });
+ await expect(page.getByText(/No data found for the selected filters/)).toHaveCount(0);
+
+ recover();
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await expect(page.getByText("Couldn't load this report")).toHaveCount(0);
+ });
+});
diff --git a/e2e/tests/management/sortable-announcements.spec.ts b/e2e/tests/management/sortable-announcements.spec.ts
new file mode 100644
index 0000000000..19b44dcac5
--- /dev/null
+++ b/e2e/tests/management/sortable-announcements.spec.ts
@@ -0,0 +1,69 @@
+import { fileURLToPath } from 'node:url';
+import type { Locator } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket } from '../../api/factory';
+import { LineupPage } from '../../pages/lineup.page';
+import { uniqueName } from '../../utils/unique';
+
+const COVER_PHOTO = fileURLToPath(new URL('../../fixtures/assets/event-cover.png', import.meta.url));
+const GALLERY_PHOTO = fileURLToPath(new URL('../../fixtures/assets/gallery-photo.png', import.meta.url));
+
+const liveRegion = (scope: Locator) => scope.locator('[id^="DndLiveRegion"]');
+
+test.describe('sortable list announcements', () => {
+ test('picking up a person keeps the pickup announcement until it moves', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const first = uniqueName('Opener');
+ const second = uniqueName('Closer');
+ for (const name of [first, second]) {
+ const profile = await api.createLineupProfile(account.organizerId, { name });
+ await api.addLineupEntry(event.eventId, { profile_id: profile.id, billing_tier: 'STANDARD' });
+ }
+
+ const lineup = new LineupPage(page);
+ await lineup.goto(event.eventId);
+ const rail = page.getByTestId('lineup-people-list').locator('xpath=..');
+ await page.getByRole('button', { name: `Drag to reorder ${first}` }).focus();
+ await page.keyboard.press('Space');
+ await expect(liveRegion(rail)).toHaveText(`Picked up ${first}, position 1 of 2.`);
+
+ await expect(async () => {
+ await page.keyboard.press('Escape');
+ await expect(liveRegion(rail)).toHaveText(`Move cancelled. ${first} is back in its original position.`, { timeout: 1000 });
+ }).toPass();
+ });
+
+ test('the photo grid announces photos by position, not by internal id', async ({ authedPage: page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const name = uniqueName('Photo Voice');
+ const profile = await api.createLineupProfile(account.organizerId, { name });
+ await api.addLineupEntry(event.eventId, { profile_id: profile.id, billing_tier: 'HEADLINER' });
+
+ const lineup = new LineupPage(page);
+ await lineup.goto(event.eventId);
+ await lineup.person(name).getByRole('button', { name: new RegExp(name) }).filter({ hasText: name }).click();
+ await lineup.uploadPhoto(COVER_PHOTO);
+ await expect(lineup.drawer().getByRole('button', { name: 'Remove photo 1' })).toBeVisible();
+ await lineup.uploadPhoto(GALLERY_PHOTO);
+
+ const grip = lineup.drawer().getByRole('button', { name: 'Move photo 2 of 2' });
+ await expect(grip).toBeVisible();
+ await expect(grip).toHaveAccessibleDescription(/^To move this item, press space or enter\./);
+
+ const announcements = liveRegion(lineup.drawer());
+ const photos = lineup.drawer().getByTestId('lineup-profile-photos').locator('[data-testid^="lineup-profile-photo-"]:not([data-testid$="-dropzone"])');
+ const sortRequest = page.waitForRequest(request => /\/profiles\/\d+\/images\/sort/.test(request.url()));
+ await grip.focus();
+ await page.keyboard.press('Space');
+ await expect(announcements).toHaveText('Picked up Photo 2, position 2 of 2.');
+
+ await expect(async () => {
+ await page.keyboard.press('ArrowLeft');
+ await expect(announcements).toHaveText('Photo 2 moved to position 1 of 2.', { timeout: 1000 });
+ }).toPass();
+ await page.keyboard.press('Space');
+ await sortRequest;
+ await expect(announcements).toHaveText('Photo 2 dropped at position 1 of 2.');
+ await expect(photos.nth(0)).toContainText('Main photo');
+ });
+});
diff --git a/e2e/tests/organizer/organizer-management.spec.ts b/e2e/tests/organizer/organizer-management.spec.ts
index 1516b1cb26..1a278796d5 100644
--- a/e2e/tests/organizer/organizer-management.spec.ts
+++ b/e2e/tests/organizer/organizer-management.spec.ts
@@ -9,7 +9,7 @@ test.describe('organizer management', () => {
const organizer = new OrganizerPage(authedPage);
await organizer.gotoEventsDashboard();
- await organizer.openCreateOrganizerModal('EO');
+ await organizer.openCreateOrganizerModal();
await expect(authedPage.getByRole('heading', { name: 'Create organizer' })).toBeVisible();
await expect(organizer.contactEmailInput).toHaveValue(account.email);
diff --git a/e2e/tests/seating/seat-change-eligibility.unit.spec.ts b/e2e/tests/seating/seat-change-eligibility.unit.spec.ts
new file mode 100644
index 0000000000..416e4c819d
--- /dev/null
+++ b/e2e/tests/seating/seat-change-eligibility.unit.spec.ts
@@ -0,0 +1,61 @@
+import {expect, test} from '@playwright/test';
+import {canAttendeeChangeSeat} from '../../../frontend/src/ee/seating/components/ChangeSeatButton/canAttendeeChangeSeat';
+import {type Attendee, EventOccurrenceStatus} from '../../../frontend/src/types';
+
+const seatedAttendee = (overrides: Partial = {}): Attendee => ({
+ product_id: 1,
+ product_price_id: 1,
+ order_id: 1,
+ status: 'ACTIVE',
+ first_name: 'Ada',
+ last_name: 'Guest',
+ email: 'ada@example.test',
+ public_id: 'A-1',
+ short_id: 'a_1',
+ seat_uid: 'e2.0.9',
+ is_checked_in: false,
+ event_occurrence: {
+ id: 1,
+ event_id: 1,
+ start_date: '2099-01-01T19:00:00Z',
+ status: EventOccurrenceStatus.ACTIVE,
+ is_future: true,
+ } as Attendee['event_occurrence'],
+ ...overrides,
+});
+
+test.describe('change seat visibility', () => {
+ test('an active, seated, not checked-in guest on a completed order for an upcoming date can change seat', () => {
+ expect(canAttendeeChangeSeat(seatedAttendee(), 'COMPLETED', true)).toBe(true);
+ });
+
+ test('self-service must be on', () => {
+ expect(canAttendeeChangeSeat(seatedAttendee(), 'COMPLETED', false)).toBe(false);
+ });
+
+ test('the order must be completed', () => {
+ expect(canAttendeeChangeSeat(seatedAttendee(), 'AWAITING_OFFLINE_PAYMENT', true)).toBe(false);
+ expect(canAttendeeChangeSeat(seatedAttendee(), 'CANCELLED', true)).toBe(false);
+ });
+
+ test('the guest must hold a seat and be active', () => {
+ expect(canAttendeeChangeSeat(seatedAttendee({seat_uid: null}), 'COMPLETED', true)).toBe(false);
+ expect(canAttendeeChangeSeat(seatedAttendee({status: 'CANCELLED'}), 'COMPLETED', true)).toBe(false);
+ expect(canAttendeeChangeSeat(seatedAttendee({status: 'AWAITING_PAYMENT'}), 'COMPLETED', true)).toBe(false);
+ });
+
+ test('a checked-in guest cannot change seat', () => {
+ expect(canAttendeeChangeSeat(seatedAttendee({is_checked_in: true}), 'COMPLETED', true)).toBe(false);
+ });
+
+ test('the date must not have started or been cancelled', () => {
+ const started = seatedAttendee();
+ started.event_occurrence = {...started.event_occurrence!, is_future: false};
+ const cancelled = seatedAttendee();
+ cancelled.event_occurrence = {...cancelled.event_occurrence!, status: EventOccurrenceStatus.CANCELLED};
+
+ expect(canAttendeeChangeSeat(started, 'COMPLETED', true)).toBe(false);
+ expect(canAttendeeChangeSeat(cancelled, 'COMPLETED', true)).toBe(false);
+ expect(canAttendeeChangeSeat(seatedAttendee({event_occurrence: undefined}), 'COMPLETED', true)).toBe(false);
+ });
+});
diff --git a/e2e/tests/seating/seat-change-visibility.spec.ts b/e2e/tests/seating/seat-change-visibility.spec.ts
new file mode 100644
index 0000000000..278f460b14
--- /dev/null
+++ b/e2e/tests/seating/seat-change-visibility.spec.ts
@@ -0,0 +1,69 @@
+import type {APIRequestContext, Page} from '@playwright/test';
+import {test, expect} from '../../fixtures';
+import {createSeatedEvent, createSeatedOrder, type SeededOrder} from '../../api/factory';
+import type {ApiClient} from '../../api/api-client';
+
+const SEAT = 'e2.0.9';
+const SEAT_LINE = 'Premium Seat · Stalls · A-10';
+
+const seatedOrderWithSeatChange = async (api: ApiClient, publicApi: APIRequestContext, organizerId: number) => {
+ const event = await createSeatedEvent(api, organizerId);
+ await api.updateSeatMapRules(event.eventId, {prevent_orphan_seats: true, max_seats_per_order: null, allow_seat_change: true});
+ const order = await createSeatedOrder(publicApi, event, [SEAT]);
+
+ return {event, order};
+};
+
+const openOrderSummary = async (page: Page, eventId: number, order: SeededOrder) => {
+ await page.goto(`/checkout/${eventId}/${order.orderShortId}/summary`);
+ await expect(page.getByText(SEAT_LINE)).toBeVisible();
+};
+
+const expectNoChangeSeatButton = async (page: Page, eventId: number, order: SeededOrder) => {
+ await openOrderSummary(page, eventId, order);
+ await page.waitForLoadState('networkidle');
+ await expect(page.getByTestId('order-change-seat-button')).toHaveCount(0);
+};
+
+test.describe('change seat button visibility', () => {
+ test.beforeEach(async ({adminApi, account}) => {
+ await adminApi.setAccountFeatureFlag(await adminApi.findAccountIdByEmail(account.email), 'seating', true);
+ });
+
+ test('is hidden when attendee self-service is off', async ({page, api, account, publicApi}) => {
+ const {event, order} = await seatedOrderWithSeatChange(api, publicApi, account.organizerId);
+ await openOrderSummary(page, event.eventId, order);
+ await expect(page.getByTestId('order-change-seat-button')).toBeVisible();
+
+ await api.updateEventSettings(event.eventId, {allow_attendee_self_edit: false});
+
+ await expectNoChangeSeatButton(page, event.eventId, order);
+ });
+
+ test('is hidden once the attendee has checked in', async ({page, api, account, publicApi}) => {
+ const {event, order} = await seatedOrderWithSeatChange(api, publicApi, account.organizerId);
+ await openOrderSummary(page, event.eventId, order);
+ await expect(page.getByTestId('order-change-seat-button')).toBeVisible();
+
+ const list = await api.createCheckInList(event.eventId, {name: 'Door'});
+ const checkIn = await publicApi.post(`public/check-in-lists/${list.short_id}/check-ins`, {
+ data: {attendees: [{public_id: order.attendees[0].publicId, action: 'check-in'}]},
+ });
+ expect(checkIn.ok()).toBe(true);
+
+ await expectNoChangeSeatButton(page, event.eventId, order);
+ });
+
+ test('is hidden once the date has started', async ({page, api, account, publicApi}) => {
+ const {event, order} = await seatedOrderWithSeatChange(api, publicApi, account.organizerId);
+ await openOrderSummary(page, event.eventId, order);
+ await expect(page.getByTestId('order-change-seat-button')).toBeVisible();
+
+ await api.updateOccurrence(event.eventId, event.occurrenceId, {
+ start_date: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
+ end_date: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
+ });
+
+ await expectNoChangeSeatButton(page, event.eventId, order);
+ });
+});
diff --git a/e2e/tests/seating/seat-lifecycle-ui.spec.ts b/e2e/tests/seating/seat-lifecycle-ui.spec.ts
index b9d47c2870..30f853722f 100644
--- a/e2e/tests/seating/seat-lifecycle-ui.spec.ts
+++ b/e2e/tests/seating/seat-lifecycle-ui.spec.ts
@@ -69,7 +69,7 @@ test.describe('reserved seating lifecycle', () => {
await adminApi.setAccountFeatureFlag(await adminApi.findAccountIdByEmail(account.email), 'seating', true);
});
- test('cancelling a date frees its seats and the cancelled ticket can take one back', async ({ authedPage, api, publicApi, account }) => {
+ test('cancelling a date frees its seats, and its cancelled tickets cannot be reactivated onto it', async ({ authedPage, api, publicApi, account }) => {
const event = await createSeatedEvent(api, account.organizerId, { recurringCount: 2 });
const [firstDate, secondDate] = event.occurrences;
const firstDateBuyer = uniqueEmail('first-date');
@@ -107,11 +107,10 @@ test.describe('reserved seating lifecycle', () => {
await attendees.goto(event.eventId);
await attendees.openRowAction(firstDateBuyer, 'Activate');
await authedPage.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
- await expect(authedPage.getByText('Successfully activated attendee')).toBeVisible();
- await expect(authedPage.getByText(/given to someone else/)).toHaveCount(0);
+ await expect(authedPage.getByText('This event occurrence has been cancelled')).toBeVisible();
- expect((await attendeeByEmail(api, event.eventId, firstDateBuyer)).status).toBe('ACTIVE');
- expect(await occupiedUids(api, event.eventId, firstDate.id)).toEqual([A10]);
+ expect((await attendeeByEmail(api, event.eventId, firstDateBuyer)).status).toBe('CANCELLED');
+ expect(await occupiedUids(api, event.eventId, firstDate.id)).toEqual([]);
});
test('best available offers a pair the orphan rule accepts and the buyer checks out', async ({ page, api, publicApi, account }) => {
diff --git a/e2e/tests/ssr/html-lang.spec.ts b/e2e/tests/ssr/html-lang.spec.ts
new file mode 100644
index 0000000000..b2e8ff3da7
--- /dev/null
+++ b/e2e/tests/ssr/html-lang.spec.ts
@@ -0,0 +1,23 @@
+import {expect, test} from '../../fixtures';
+
+test.describe('server-rendered document language', () => {
+ for (const [name, headers, lang] of [
+ ['the Accept-Language header', {'Accept-Language': 'de-DE,de;q=0.9'}, 'de'],
+ ['the locale cookie', {Cookie: 'locale=pt-br'}, 'pt-br'],
+ ['a locale with an Intl override', {Cookie: 'locale=se'}, 'sv'],
+ ['an unsupported language', {'Accept-Language': 'xx-YY'}, 'en'],
+ ] as const) {
+ test(`the html element declares the language picked from ${name}`, async ({request}) => {
+ const html = await (await request.get('/auth/login', {headers})).text();
+
+ expect(html).toContain(``);
+ });
+ }
+
+ test('the language survives hydration', async ({page}) => {
+ await page.context().addCookies([{name: 'locale', value: 'fr', url: test.info().project.use.baseURL!}]);
+ await page.goto('/auth/login');
+
+ await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
+ });
+});
diff --git a/e2e/tests/ssr/ssr-locale-hydration.spec.ts b/e2e/tests/ssr/ssr-locale-hydration.spec.ts
new file mode 100644
index 0000000000..44aa8fb699
--- /dev/null
+++ b/e2e/tests/ssr/ssr-locale-hydration.spec.ts
@@ -0,0 +1,137 @@
+import {expect, test} from '../../fixtures';
+import type {Page} from '@playwright/test';
+import {createLiveEventWithProduct, type SeededEvent} from '../../api/factory';
+import type {ApiClient} from '../../api/api-client';
+
+const nineInTheEveningUtc = (): string => {
+ const date = new Date();
+ date.setUTCDate(date.getUTCDate() + 30);
+ date.setUTCHours(21, 0, 0, 0);
+ return date.toISOString();
+};
+
+const hydrationErrorsOn = (page: Page): string[] => {
+ const errors: string[] = [];
+ page.on('pageerror', error => errors.push(error.message));
+ page.on('console', message => {
+ if (message.type() === 'error') {
+ errors.push(message.text());
+ }
+ });
+ return errors;
+};
+
+const expectCleanHydration = async (page: Page, errors: string[]) => {
+ await expect.poll(() => page.evaluate(() => Array.from(document.querySelectorAll('#app div'))
+ .some(element => Object.keys(element).some(key => key.startsWith('__reactFiber$'))))).toBe(true);
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 250))));
+
+ expect(errors.filter(error => /hydrat|#418|#423|#425/i.test(error))).toEqual([]);
+};
+
+const createPaidEvent = (api: ApiClient, organizerId: number): Promise =>
+ createLiveEventWithProduct(api, {organizerId, price: 25, startDate: nineInTheEveningUtc()});
+
+test.describe('server-rendered dates and prices follow the visitor locale', () => {
+ test.describe('German browser', () => {
+ test.use({locale: 'de-DE'});
+
+ test('the event page is rendered in German on the server and hydrates cleanly', async ({page, request, api, account}) => {
+ const event = await createPaidEvent(api, account.organizerId);
+ const path = `/event/${event.eventId}/${event.slug}`;
+
+ const html = await (await request.get(path, {headers: {'Accept-Language': 'de-DE,de;q=0.9'}})).text();
+ expect(html).toMatch(/\s21:00);
+ expect(html).not.toContain('9:00 PM');
+
+ const errors = hydrationErrorsOn(page);
+ await page.goto(path);
+ await expectCleanHydration(page, errors);
+ await expect(page.locator('html')).toHaveAttribute('lang', 'de');
+ });
+
+ test('the ticket widget formats prices in German on the server and hydrates cleanly', async ({page, request, api, account}) => {
+ const event = await createPaidEvent(api, account.organizerId);
+ const path = `/widget/${event.eventId}`;
+
+ const html = await (await request.get(path, {headers: {'Accept-Language': 'de-DE,de;q=0.9'}})).text();
+ expect(html).toMatch(/\d+,\d{2}\s\$/);
+
+ const errors = hydrationErrorsOn(page);
+ await page.goto(path);
+ await expectCleanHydration(page, errors);
+ });
+ });
+
+ test.describe('British browser', () => {
+ test.use({locale: 'en-GB'});
+
+ test('the ticket widget hydrates cleanly', async ({page, api, account}) => {
+ const event = await createPaidEvent(api, account.organizerId);
+
+ const errors = hydrationErrorsOn(page);
+ await page.goto(`/widget/${event.eventId}`);
+ await expectCleanHydration(page, errors);
+ });
+ });
+
+ test.describe('American browser', () => {
+ test.use({locale: 'en-US'});
+
+ test('a French language cookie renders the event page in French on the server and hydrates cleanly', async ({page, request, api, account, baseURL}) => {
+ const event = await createPaidEvent(api, account.organizerId);
+ const path = `/event/${event.eventId}/${event.slug}`;
+
+ const html = await (await request.get(path, {headers: {'Accept-Language': 'en-US', Cookie: 'locale=fr'}})).text();
+ expect(html).toMatch(/\s21:00);
+ expect(html).not.toContain('9:00 PM');
+
+ await page.context().addCookies([{name: 'locale', value: 'fr', url: baseURL!}]);
+ const errors = hydrationErrorsOn(page);
+ await page.goto(path);
+ await expectCleanHydration(page, errors);
+ await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
+ });
+
+ test('a Swedish language cookie tags the page with the sv language code', async ({page, baseURL}) => {
+ await page.context().addCookies([{name: 'locale', value: 'se', url: baseURL!}]);
+ const errors = hydrationErrorsOn(page);
+ await page.goto('/auth/login');
+ await expectCleanHydration(page, errors);
+ await expect(page.locator('html')).toHaveAttribute('lang', 'sv');
+ });
+
+ test('a widget embedded in French renders French prices on the server and hydrates cleanly', async ({page, request, api, account}) => {
+ const event = await createPaidEvent(api, account.organizerId);
+ const path = `/widget/${event.eventId}?Locale=fr`;
+
+ const html = await (await request.get(path, {headers: {'Accept-Language': 'en-US'}})).text();
+ expect(html).toMatch(/\d+,\d{2}\s\$US/);
+
+ const errors = hydrationErrorsOn(page);
+ await page.goto(path);
+ await expectCleanHydration(page, errors);
+ });
+
+ test('a schedule widget embedded in German renders German days and times on the server and hydrates cleanly', async ({page, request, api, account}) => {
+ const event = await createPaidEvent(api, account.organizerId);
+ await api.importLineup(event.eventId, {
+ people: [{name: 'Velvet Harbour', billing_tier: 'HEADLINER'}, {name: 'Ada Byron'}],
+ slots: [
+ {starts_at: '2030-06-14 21:30:00', ends_at: '2030-06-14 23:00:00', stage: 'Main Stage', people: ['Velvet Harbour']},
+ {starts_at: '2030-06-15 18:00:00', ends_at: '2030-06-15 19:00:00', stage: 'Main Stage', people: ['Ada Byron']},
+ ],
+ });
+ const path = `/widget/${event.eventId}/schedule?Locale=de`;
+
+ const html = await (await request.get(path, {headers: {'Accept-Language': 'en-US'}})).text();
+ expect(html).toContain('Freitag');
+ expect(html).toMatch(/>21:30);
+ expect(html).not.toContain('9:30 PM');
+
+ const errors = hydrationErrorsOn(page);
+ await page.goto(path);
+ await expectCleanHydration(page, errors);
+ });
+ });
+});
diff --git a/e2e/tests/utils/unique.unit.spec.ts b/e2e/tests/utils/unique.unit.spec.ts
new file mode 100644
index 0000000000..71a7d563fd
--- /dev/null
+++ b/e2e/tests/utils/unique.unit.spec.ts
@@ -0,0 +1,13 @@
+import { expect, test } from '@playwright/test';
+import { uniqueShort } from '../../utils/unique';
+
+test.describe('unique test data', () => {
+ test('short names are letters only so they always pass the person name check', () => {
+ const names = Array.from({ length: 5000 }, () => uniqueShort('Renamed'));
+
+ for (const name of names) {
+ expect(name).toMatch(/^Renamed [a-z]{6}$/);
+ }
+ expect(new Set(names).size).toBeGreaterThan(4990);
+ });
+});
diff --git a/e2e/tests/widget/embed-seats-unavailable.spec.ts b/e2e/tests/widget/embed-seats-unavailable.spec.ts
new file mode 100644
index 0000000000..856aa3c2a4
--- /dev/null
+++ b/e2e/tests/widget/embed-seats-unavailable.spec.ts
@@ -0,0 +1,37 @@
+import { test, expect } from '../../fixtures';
+import { createSeatedEvent } from '../../api/factory';
+import { checkoutModal, openEmbeddedWidget, resolveCheckoutFrame } from '../../pages/embedded-widget';
+
+test.describe('embedded seat picker availability', () => {
+ test.beforeEach(async ({ adminApi, account }) => {
+ await adminApi.setAccountFeatureFlag(await adminApi.findAccountIdByEmail(account.email), 'seating', true);
+ });
+
+ test('the seat picker shows a loader and then a message when the event cannot be loaded', async ({ page, api, account, baseURL }) => {
+ const event = await createSeatedEvent(api, account.organizerId);
+ const widgetFrame = await openEmbeddedWidget(page, baseURL!, event.eventId);
+ await expect(widgetFrame.getByTestId('choose-seats-button')).toBeVisible();
+
+ let releaseEventRequest: () => void = () => undefined;
+ const eventRequestHeld = new Promise((resolve) => {
+ releaseEventRequest = resolve;
+ });
+ const publicEventPath = new RegExp(`/public/events/${event.eventId}$`);
+ await page.route((url) => publicEventPath.test(url.pathname), async (route) => {
+ await eventRequestHeld;
+ await route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ message: 'Not found' }) });
+ });
+
+ await widgetFrame.getByTestId('choose-seats-button').click();
+ await expect(checkoutModal(page)).toBeVisible();
+ const seatFrame = await resolveCheckoutFrame(page);
+ await seatFrame.waitForURL(/\/checkout\/\d+\/seats/);
+
+ await expect(seatFrame.getByTestId('seat-checkout-loading')).toBeVisible();
+ releaseEventRequest();
+
+ await expect(seatFrame.getByText('This event is not available')).toBeVisible();
+ await expect(seatFrame.getByText('It may have been unpublished or removed.', { exact: false })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Close checkout' })).toBeVisible();
+ });
+});
diff --git a/e2e/tests/widget/embed-seats.spec.ts b/e2e/tests/widget/embed-seats.spec.ts
index 0ac9ca42ce..2331bf747d 100644
--- a/e2e/tests/widget/embed-seats.spec.ts
+++ b/e2e/tests/widget/embed-seats.spec.ts
@@ -1,8 +1,9 @@
import { test, expect } from '../../fixtures';
import { createSeatedEvent } from '../../api/factory';
-import { checkoutModal, openEmbeddedWidget, resolveCheckoutFrame } from '../../pages/embedded-widget';
+import { checkoutModal, openEmbedHost, openEmbeddedWidget, resolveCheckoutFrame, resolveWidgetFrame, widgetMarkup } from '../../pages/embedded-widget';
import { CheckoutPage } from '../../pages/checkout.page';
import { uniqueCode, uniqueEmail } from '../../utils/unique';
+import { WidgetApi } from './widget-api';
test.describe('embedded widget reserved seating', () => {
test.beforeEach(async ({ adminApi, account }) => {
@@ -37,6 +38,36 @@ test.describe('embedded widget reserved seating', () => {
expect(attendee.seat_label).toBe('Stalls · A-10');
});
+ test('a seated order from a saved widget keeps its widget and campaign through the seat picker', async ({ page, api, account, baseURL, playwright }) => {
+ const event = await createSeatedEvent(api, account.organizerId);
+ const saved = await api.createEventWidget(event.eventId, { name: 'Seated site', type: 'TICKETS', settings: {} });
+
+ await openEmbedHost(page, baseURL!, widgetMarkup({ id: String(event.eventId), widget: saved.short_id }), '?utm_source=newsletter');
+ const widgetFrame = await resolveWidgetFrame(page);
+ await widgetFrame.getByTestId('choose-seats-button').click();
+
+ const seatFrame = await resolveCheckoutFrame(page);
+ await seatFrame.waitForURL(/\/checkout\/\d+\/seats/);
+ const seatUrl = new URL(seatFrame.url());
+ expect(seatUrl.searchParams.get('Widget')).toBe(saved.short_id);
+ expect(seatUrl.searchParams.get('utm_source')).toBe('newsletter');
+
+ await seatFrame.getByTestId('seat-picker-map').locator('[data-uid="e2.0.9"]').click();
+ await seatFrame.getByTestId('seat-picker-continue-button').click();
+ await seatFrame.waitForURL(/\/checkout\/\d+\/[^/]+\/details/);
+ expect(new URL(seatFrame.url()).searchParams.get('utm_source')).toBe('newsletter');
+
+ const checkout = new CheckoutPage(page, seatFrame);
+ await checkout.fillOrderAndAttendees({ firstName: 'Sam', lastName: 'Seated', email: uniqueEmail('seatwidget') }, 1);
+ await checkout.completeFreeOrder();
+ await expect(seatFrame.getByText("You're going to", { exact: false }).first()).toBeVisible();
+
+ const widgets = await WidgetApi.create(playwright, account.token);
+ await expect.poll(async () => (await widgets.listEventWidgets(event.eventId)).find((candidate) => candidate.id === saved.id)?.stats.orders)
+ .toBe(1);
+ await widgets.dispose();
+ });
+
test('a promo code applied in the widget carries into the host page seat picker', async ({ page, api, account, baseURL }) => {
const event = await createSeatedEvent(api, account.organizerId, { price: 30 });
await api.updateEventSettings(event.eventId, { pass_platform_fee_to_buyer: false });
diff --git a/e2e/tests/widget/embedded-lineup-dialogs.spec.ts b/e2e/tests/widget/embedded-lineup-dialogs.spec.ts
new file mode 100644
index 0000000000..0d26d34456
--- /dev/null
+++ b/e2e/tests/widget/embedded-lineup-dialogs.spec.ts
@@ -0,0 +1,99 @@
+import { type Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket } from '../../api/factory';
+import { openEmbedHost, resolveWidgetFrame, widgetMarkup } from '../../pages/embedded-widget';
+import { uniqueName } from '../../utils/unique';
+
+const SPACER = 'Host page content';
+
+const iframeHeight = (page: Page, title: string) =>
+ page.locator(`iframe[title="${title}"]`).evaluate((frame) => frame.getBoundingClientRect().height);
+
+const settledIframeHeight = async (page: Page, title: string) => {
+ await page.locator(`iframe[title="${title}"]`).scrollIntoViewIfNeeded();
+ let previous = -1;
+ await expect.poll(async () => {
+ const current = await iframeHeight(page, title);
+ const settled = current === previous && current > 150;
+ previous = current;
+ return settled;
+ }, { intervals: [300] }).toBe(true);
+ return previous;
+};
+
+const scrollIframeTopAboveViewport = (page: Page, title: string, distance: number) =>
+ page.evaluate(([selector, by]) => {
+ const frame = document.querySelector(selector)!;
+ window.scrollTo(0, frame.getBoundingClientRect().top + window.scrollY + by);
+ }, [`iframe[title="${title}"]`, distance] as const);
+
+for (const viewport of [{ name: 'desktop', width: 1280, height: 800 }, { name: 'phone', width: 390, height: 664 }]) {
+ test.describe(`embedded lineup dialogs on ${viewport.name}`, () => {
+ test.use({ viewport: { width: viewport.width, height: viewport.height } });
+
+ test('a session opened after scrolling past the top of a short schedule widget is fully in view', async ({ page, api, account, baseURL }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const slots = Array.from({ length: 5 }, (_, index) => ({
+ starts_at: `2030-06-14 1${index}:00:00`,
+ ends_at: `2030-06-14 1${index}:45:00`,
+ stage: 'Main Stage',
+ title: `Session ${index + 1}`,
+ description: Array.from({ length: 10 }, (_, paragraph) => `Part ${paragraph + 1} of a session description long enough to fill the dialog.
`).join(''),
+ people: [],
+ }));
+ await api.importLineup(event.eventId, { people: [], slots });
+
+ await openEmbedHost(page, baseURL!, `${SPACER}${widgetMarkup({ id: String(event.eventId), 'widget-type': 'schedule' })}${SPACER}`);
+ const schedule = await resolveWidgetFrame(page, 'Hi.Events Schedule');
+ await expect(schedule.getByTestId('event-schedule-item')).toHaveCount(5);
+ const collapsedHeight = await settledIframeHeight(page, 'Hi.Events Schedule');
+
+ await scrollIframeTopAboveViewport(page, 'Hi.Events Schedule', 200);
+ await schedule.getByRole('button', { name: 'Session 4' }).click();
+
+ const dialog = schedule.getByRole('dialog', { name: 'Session 4' });
+ await expect(dialog.getByRole('heading', { name: 'Session 4', level: 3 })).toBeVisible();
+ await expect.poll(() => iframeHeight(page, 'Hi.Events Schedule')).toBeGreaterThan(collapsedHeight);
+ await expect.poll(async () => (await dialog.boundingBox())!.height).toBeGreaterThan(300);
+
+ const dialogBox = (await dialog.boundingBox())!;
+ const closeBox = (await dialog.getByRole('button', { name: 'Close' }).boundingBox())!;
+ expect(dialogBox.y).toBeGreaterThanOrEqual(0);
+ expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(viewport.height);
+ expect(closeBox.y).toBeGreaterThanOrEqual(0);
+
+ await dialog.getByRole('button', { name: 'Close' }).click();
+ await expect(dialog).toBeHidden();
+ await expect.poll(() => iframeHeight(page, 'Hi.Events Schedule')).toBe(collapsedHeight);
+ });
+
+ test('a profile with a long bio in a one-person lineup widget gets a full-size dialog', async ({ page, api, account, baseURL }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const name = uniqueName('Solo Headliner');
+ const profile = await api.createLineupProfile(account.organizerId, {
+ name,
+ bio: Array.from({ length: 12 }, (_, index) => `Chapter ${index + 1} of a biography long enough to need its own scroll area.
`).join(''),
+ });
+ await api.addLineupEntry(event.eventId, { profile_id: profile.id, billing_tier: 'FEATURED' });
+
+ await openEmbedHost(page, baseURL!, `${SPACER}${widgetMarkup({ id: String(event.eventId), 'widget-type': 'lineup' })}${SPACER}`);
+ const lineup = await resolveWidgetFrame(page, 'Hi.Events Lineup');
+ const card = lineup.getByRole('button', { name: `About ${name}` });
+ await expect(card).toBeVisible();
+ const collapsedHeight = await settledIframeHeight(page, 'Hi.Events Lineup');
+ await card.click();
+
+ const dialog = lineup.getByRole('dialog', { name });
+ await expect(dialog.getByRole('heading', { name, level: 3 })).toBeVisible();
+ await expect.poll(async () => (await dialog.boundingBox())!.height).toBeGreaterThan(Math.min(collapsedHeight, 400));
+
+ const dialogBox = (await dialog.boundingBox())!;
+ expect(dialogBox.y).toBeGreaterThanOrEqual(0);
+ expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(viewport.height);
+
+ await page.keyboard.press('Escape');
+ await expect(dialog).toBeHidden();
+ await expect.poll(() => iframeHeight(page, 'Hi.Events Lineup')).toBe(collapsedHeight);
+ });
+ });
+}
diff --git a/e2e/tests/widget/price-before-discount-inclusive.unit.spec.ts b/e2e/tests/widget/price-before-discount-inclusive.unit.spec.ts
new file mode 100644
index 0000000000..81ef8c50d0
--- /dev/null
+++ b/e2e/tests/widget/price-before-discount-inclusive.unit.spec.ts
@@ -0,0 +1,83 @@
+import { expect, test } from '@playwright/test';
+import { getBeforeDiscountDisplayPrice } from '../../../frontend/src/components/routes/product-widget/SelectProducts/priceTotals';
+import { ProductPrice } from '../../../frontend/src/types';
+
+test.describe('inclusive struck price from the server', () => {
+ const cloudDefaultPromos = [
+ {
+ name: 'no tax',
+ price: { price: 18.0, tax_total: 0, fee_total: 0.27, price_before_discount_including_taxes_and_fees: 20.3 },
+ strike: 20.3,
+ },
+ {
+ name: '23% VAT',
+ price: { price: 18.0, tax_total: 4.14, fee_total: 0.34, price_before_discount_including_taxes_and_fees: 24.97 },
+ strike: 24.97,
+ },
+ ];
+
+ for (const promo of cloudDefaultPromos) {
+ test(`a 10% promo on a $20 Cloud default event (${promo.name}) strikes the full inclusive price`, () => {
+ const price: ProductPrice = { id: 100, price_before_discount: 20.0, is_discounted: true, ...promo.price };
+
+ expect(getBeforeDiscountDisplayPrice(price, 'INCLUSIVE')).toBe(promo.strike);
+ expect(getBeforeDiscountDisplayPrice(price, 'EXCLUSIVE')).toBe(20.0);
+ expect(getBeforeDiscountDisplayPrice(price, undefined)).toBe(20.0);
+ });
+ }
+
+ test('a full discount still strikes the full inclusive price', () => {
+ const price: ProductPrice = {
+ id: 100,
+ price: 0,
+ price_before_discount: 20.0,
+ price_before_discount_including_taxes_and_fees: 24.97,
+ is_discounted: true,
+ tax_total: 0,
+ fee_total: 0,
+ };
+
+ expect(getBeforeDiscountDisplayPrice(price, 'INCLUSIVE')).toBe(24.97);
+ });
+
+ test('the server value is used even when the published totals cannot be explained locally', () => {
+ const price: ProductPrice = {
+ id: 100,
+ price: 9.0,
+ price_before_discount: 10.0,
+ price_before_discount_including_taxes_and_fees: 10.65,
+ is_discounted: true,
+ tax_total: 0,
+ fee_total: 0.58,
+ };
+
+ expect(getBeforeDiscountDisplayPrice(price, 'INCLUSIVE')).toBe(10.65);
+ });
+
+ test('without the server value there is no inclusive strike', () => {
+ const discounted: ProductPrice = {
+ id: 100,
+ price: 18.0,
+ price_before_discount: 20.0,
+ is_discounted: true,
+ tax_total: 0,
+ fee_total: 0.27,
+ };
+
+ expect(getBeforeDiscountDisplayPrice(discounted, 'INCLUSIVE')).toBeNull();
+ expect(getBeforeDiscountDisplayPrice({ ...discounted, price_before_discount_including_taxes_and_fees: null }, 'INCLUSIVE')).toBeNull();
+ expect(getBeforeDiscountDisplayPrice(discounted, 'EXCLUSIVE')).toBe(20.0);
+ });
+
+ test('an undiscounted price has no struck price even if a value is present', () => {
+ const price: ProductPrice = {
+ id: 100,
+ price: 20.0,
+ price_before_discount_including_taxes_and_fees: 24.97,
+ tax_total: 4.6,
+ fee_total: 0.37,
+ };
+
+ expect(getBeforeDiscountDisplayPrice(price, 'INCLUSIVE')).toBeNull();
+ });
+});
diff --git a/e2e/tests/widget/price-totals.unit.spec.ts b/e2e/tests/widget/price-totals.unit.spec.ts
new file mode 100644
index 0000000000..c70de8ddde
--- /dev/null
+++ b/e2e/tests/widget/price-totals.unit.spec.ts
@@ -0,0 +1,107 @@
+import { expect, test } from '@playwright/test';
+import {
+ getBeforeDiscountDisplayPrice,
+ getLineTotal,
+} from '../../../frontend/src/components/routes/product-widget/SelectProducts/priceTotals';
+import {
+ Product,
+ ProductPrice,
+ ProductPriceType,
+ ProductType,
+ TaxAndFee,
+ TaxAndFeeCalculationType,
+ TaxAndFeeType,
+} from '../../../frontend/src/types';
+
+const vat = (rate: number): TaxAndFee => ({
+ id: 2,
+ name: 'VAT',
+ rate,
+ type: TaxAndFeeType.Tax,
+ calculation_type: TaxAndFeeCalculationType.Percentage,
+ is_default: false,
+ is_active: true,
+});
+
+const fixedFee = (rate: number, id = 1): TaxAndFee => ({
+ id,
+ name: id === 0 ? 'Platform Fee' : 'Booking fee',
+ rate,
+ type: TaxAndFeeType.Fee,
+ calculation_type: TaxAndFeeCalculationType.Fixed,
+ is_default: false,
+ is_active: true,
+});
+
+const product = (taxes: TaxAndFee[]): Product => ({
+ id: 1,
+ title: 'Ticket',
+ type: ProductPriceType.Paid,
+ product_type: ProductType.Ticket,
+ taxes,
+});
+
+test.describe('ticket price totals', () => {
+ const awkwardVatOrders = [
+ { name: 'no promo', price: 9.99, taxTotal: 2.30, orderTotal: 36.86 },
+ { name: '15% promo', price: 8.49, taxTotal: 1.95, orderTotal: 31.33 },
+ { name: '1.33 fixed promo', price: 8.66, taxTotal: 1.99, orderTotal: 31.96 },
+ ];
+
+ for (const order of awkwardVatOrders) {
+ test(`inclusive cart total for 3 x ${order.price} with 23% VAT (${order.name}) equals the server order total`, () => {
+ const price: ProductPrice = { id: 100, price: order.price, tax_total: order.taxTotal, fee_total: 0 };
+
+ expect(getLineTotal(product([vat(23)]), price, 3, 'INCLUSIVE')).toBe(order.orderTotal);
+ });
+ }
+
+ test('inclusive cart total for a discounted per-date price with a fixed fee and VAT equals the server order total', () => {
+ const price: ProductPrice = {
+ id: 100,
+ price: 10.0,
+ price_before_discount: 12.5,
+ is_discounted: true,
+ tax_total: 2.65,
+ fee_total: 1.5,
+ };
+
+ expect(getLineTotal(product([fixedFee(1.5), vat(23)]), price, 3, 'INCLUSIVE')).toBe(42.44);
+ });
+
+ test('exclusive cart total is the pre-tax subtotal', () => {
+ const price: ProductPrice = { id: 100, price: 10.01, tax_total: 2.3, fee_total: 0 };
+
+ expect(getLineTotal(product([vat(23)]), price, 3, 'EXCLUSIVE')).toBe(30.03);
+ });
+
+ test('an unreproducible platform fee falls back to the per-unit inclusive price', () => {
+ const price: ProductPrice = { id: 100, price: 10.0, tax_total: 0, fee_total: 0.62 };
+
+ expect(getLineTotal(product([fixedFee(0, 0)]), price, 3, 'INCLUSIVE')).toBe(31.86);
+ });
+
+ test('the struck price of a discounted per-date price matches each display mode', () => {
+ const price: ProductPrice = {
+ id: 100,
+ price: 10.0,
+ price_before_discount: 12.5,
+ price_before_discount_including_taxes_and_fees: 17.22,
+ is_discounted: true,
+ tax_total: 2.65,
+ fee_total: 1.5,
+ };
+
+ expect(getBeforeDiscountDisplayPrice(price, 'EXCLUSIVE')).toBe(12.5);
+
+ const inclusiveStrike = getBeforeDiscountDisplayPrice(price, 'INCLUSIVE');
+ expect(inclusiveStrike).toBe(17.22);
+ expect(inclusiveStrike).toBeGreaterThan(price.price + 2.65 + 1.5);
+ });
+
+ test('an undiscounted price has no struck price', () => {
+ const price: ProductPrice = { id: 100, price: 9.99, tax_total: 2.3, fee_total: 0 };
+
+ expect(getBeforeDiscountDisplayPrice(price, 'INCLUSIVE')).toBeNull();
+ });
+});
diff --git a/e2e/tests/widget/theme-contrast.unit.spec.ts b/e2e/tests/widget/theme-contrast.unit.spec.ts
new file mode 100644
index 0000000000..90e1e86fd4
--- /dev/null
+++ b/e2e/tests/widget/theme-contrast.unit.spec.ts
@@ -0,0 +1,33 @@
+import { expect, test } from '@playwright/test';
+import { checkContrast, getContrastColor } from '../../../frontend/src/utilites/themeUtils';
+
+test.describe('theme button text colour', () => {
+ for (const accent of ['#ef4444', '#3b82f6', '#ec4899', '#22c55e', '#f59e0b', '#7c3aed', '#1e40af', '#000000', '#ffffff']) {
+ test(`text on ${accent} is the more readable of the two text colours`, () => {
+ const chosen = getContrastColor(accent);
+ const other = chosen === '#ffffff' ? '#1a1a1a' : '#ffffff';
+
+ expect(checkContrast(chosen, accent).ratio).toBeGreaterThanOrEqual(checkContrast(other, accent).ratio);
+ });
+ }
+
+ test('red and mid-blue accents that failed with white text now pass AA', () => {
+ expect(checkContrast(getContrastColor('#ef4444'), '#ef4444').passesAA).toBe(true);
+ expect(checkContrast(getContrastColor('#3b82f6'), '#3b82f6').passesAA).toBe(true);
+ });
+
+ test('accents that already passed keep their text colour', () => {
+ expect(getContrastColor('#7c3aed')).toBe('#ffffff');
+ expect(getContrastColor('#1e40af')).toBe('#ffffff');
+ expect(getContrastColor('#22c55e')).toBe('#1a1a1a');
+ expect(getContrastColor('#f5f3ff')).toBe('#1a1a1a');
+ });
+
+ test('the default accent keeps white text', () => {
+ expect(getContrastColor('#8b5cf6')).toBe('#ffffff');
+ });
+
+ test('an unparsable colour keeps white text', () => {
+ expect(getContrastColor('var(--accent)')).toBe('#ffffff');
+ });
+});
diff --git a/e2e/tests/widget/theme-tertiary-contrast.unit.spec.ts b/e2e/tests/widget/theme-tertiary-contrast.unit.spec.ts
new file mode 100644
index 0000000000..b2bcaafbbd
--- /dev/null
+++ b/e2e/tests/widget/theme-tertiary-contrast.unit.spec.ts
@@ -0,0 +1,18 @@
+import { expect, test } from '@playwright/test';
+import { checkContrast, getDerivedColors } from '../../../frontend/src/utilites/themeUtils';
+
+test.describe('event theme low-emphasis text', () => {
+ for (const mode of ['light', 'dark'] as const) {
+ test(`tertiary text on the ${mode} surface passes AA`, () => {
+ const { textTertiary, surface } = getDerivedColors(mode);
+
+ expect(checkContrast(textTertiary, surface).ratio).toBeGreaterThanOrEqual(4.5);
+ });
+
+ test(`secondary text on the ${mode} surface passes AA`, () => {
+ const { textSecondary, surface } = getDerivedColors(mode);
+
+ expect(checkContrast(textSecondary, surface).ratio).toBeGreaterThanOrEqual(4.5);
+ });
+ }
+});
diff --git a/e2e/tests/widget/widget-api.ts b/e2e/tests/widget/widget-api.ts
new file mode 100644
index 0000000000..8878645514
--- /dev/null
+++ b/e2e/tests/widget/widget-api.ts
@@ -0,0 +1,49 @@
+import { type APIRequest, type APIRequestContext, expect } from '@playwright/test';
+import { API_BASE_URL } from '../../utils/env';
+
+export interface SavedWidget {
+ id: number;
+ short_id: string;
+ name: string;
+ settings: Record;
+ stats: { views: number; orders: number; revenue?: Record };
+}
+
+const data = async (response: Promise): Promise => {
+ const resolved = await response;
+ expect(resolved.ok(), `${resolved.url()} ${resolved.status()} ${await resolved.text()}`).toBeTruthy();
+ return (await resolved.json()).data as T;
+};
+
+export class WidgetApi {
+ private constructor(private readonly request: APIRequestContext) {}
+
+ static async create(playwright: { request: APIRequest }, token: string): Promise {
+ return new WidgetApi(await playwright.request.newContext({
+ baseURL: API_BASE_URL,
+ ignoreHTTPSErrors: true,
+ extraHTTPHeaders: { Accept: 'application/json', Authorization: `Bearer ${token}` },
+ }));
+ }
+
+ createOrganizerWidget(organizerId: number, payload: { name: string; type: 'EVENTS'; settings: Record }): Promise {
+ return data(this.request.post(`organizers/${organizerId}/widgets`, { data: payload }));
+ }
+
+ listEventWidgets(eventId: number): Promise {
+ return data(this.request.get(`events/${eventId}/widgets`));
+ }
+
+ listOrganizerWidgets(organizerId: number): Promise {
+ return data(this.request.get(`organizers/${organizerId}/widgets`));
+ }
+
+ async deleteProduct(eventId: number, productId: number): Promise {
+ const response = await this.request.delete(`events/${eventId}/products/${productId}`);
+ expect(response.ok(), await response.text()).toBeTruthy();
+ }
+
+ dispose(): Promise {
+ return this.request.dispose();
+ }
+}
diff --git a/e2e/tests/widget/widget-config.unit.spec.ts b/e2e/tests/widget/widget-config.unit.spec.ts
index 7c5e978698..4bbdd05ac5 100644
--- a/e2e/tests/widget/widget-config.unit.spec.ts
+++ b/e2e/tests/widget/widget-config.unit.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
-import { resolveWidgetConfig } from '../../../frontend/src/utilites/widgetConfig';
+import { forwardedHostParams, resolveWidgetConfig } from '../../../frontend/src/utilites/widgetConfig';
import { agentInstructions, settingsForType, widgetAttributes } from '../../../frontend/src/components/common/WidgetEditor/embedCode';
test.describe('widget config', () => {
@@ -42,6 +42,12 @@ test.describe('widget config', () => {
expect(config.sections).toEqual(['header', 'tickets']);
expect(config.prefill).toEqual({ email: 'a@b.c', first_name: 'Ann' });
});
+
+ test('host page attribution parameters are picked out of the widget URL', () => {
+ const search = new URLSearchParams('Layout=grid&aff=PARTNER&promo_code=SAVE10&utm_source=news&utm_campaign=&iframeId=x');
+
+ expect(forwardedHostParams(search)).toEqual({ aff: 'PARTNER', promo_code: 'SAVE10', utm_source: 'news' });
+ });
});
test.describe('widget embed code', () => {
@@ -106,4 +112,12 @@ test.describe('widget agent instructions', () => {
expect(brief).not.toContain('hievents:order-completed');
expect(brief).not.toContain('prefill');
});
+
+ test('a fixed-height embed tells the agent to give the widget element a height', () => {
+ const fixed = agentInstructions('TICKETS', widgetAttributes({ eventId: '7' }, 'TICKETS', { autoresize: false }), 'https://tickets.example.com/widget.js');
+ const auto = agentInstructions('TICKETS', widgetAttributes({ eventId: '7' }, 'TICKETS', {}), 'https://tickets.example.com/widget.js');
+
+ expect(fixed).toContain('give the widget element the height it should take');
+ expect(auto).toContain("so don't give it a fixed height.");
+ });
});
diff --git a/e2e/tests/widget/widget-editor.spec.ts b/e2e/tests/widget/widget-editor.spec.ts
index 11e0e2ac78..a6616a51fc 100644
--- a/e2e/tests/widget/widget-editor.spec.ts
+++ b/e2e/tests/widget/widget-editor.spec.ts
@@ -2,7 +2,8 @@ import { test, expect } from '../../fixtures';
import { CheckoutPage, setWidgetQuantity } from '../../pages/checkout.page';
import { openEmbedHost, openModalCheckout, resolveWidgetFrame, widgetMarkup } from '../../pages/embedded-widget';
import { createLiveEventWithFreeTicket } from '../../api/factory';
-import { uniqueEmail } from '../../utils/unique';
+import { uniqueEmail, uniqueName } from '../../utils/unique';
+import { WidgetApi } from './widget-api';
test.describe('widget editor', () => {
test('a saved widget keeps its settings wherever it is embedded, its edits go live and its sales show up', async ({ authedPage: page, api, account, baseURL, browser }) => {
@@ -21,6 +22,9 @@ test.describe('widget editor', () => {
await page.getByRole('dialog', { name: 'Save widget' }).getByLabel('Name').fill('Homepage');
await page.getByTestId('widget-name-submit-button').click();
await expect(page.getByTestId('widget-stats')).toBeVisible();
+ await expect(page.getByText('Copy the new code to your site')).toBeVisible();
+ await page.getByTestId('widget-copy-code-button').click();
+ await expect(page.getByText('Saved', { exact: true })).toBeVisible();
const code = await page.getByTestId('widget-embed-code').textContent();
const shortId = code?.match(/data-hievents-widget="(wgt_[^"]+)"/)?.[1];
@@ -101,10 +105,78 @@ test.describe('widget editor', () => {
await expect(page.getByTestId('widget-stats')).toHaveCount(0);
});
- test('organizers embed their events list from the organizer area', async ({ authedPage: page, api, account }) => {
+ test('a saved widget whose ticket was deleted drops it, and can still be renamed', async ({ authedPage: page, api, account, playwright }) => {
const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const categories = await api.listProductCategories(event.eventId);
+ const vip = await api.createProduct(event.eventId, {
+ title: 'VIP Pass',
+ product_type: 'TICKET',
+ type: 'FREE',
+ product_category_id: categories[0].id,
+ prices: [{ price: 0 }],
+ });
+ const widgetName = uniqueName('VIP only');
+ await api.createEventWidget(event.eventId, { name: widgetName, type: 'TICKETS', settings: { products: [vip.id] } });
+ const widgets = await WidgetApi.create(playwright, account.token);
+ await widgets.deleteProduct(event.eventId, vip.id);
- await page.goto(`/manage/organizer/${account.organizerId}`);
+ await page.goto(`/manage/event/${event.eventId}/widget`);
+ await page.getByTestId('widget-switcher').click();
+ await page.getByRole('option', { name: widgetName }).click();
+ await expect(page.getByTestId('widget-group-tickets')).toContainText('All tickets');
+ await expect(page.getByText('Unsaved changes')).toBeVisible();
+
+ await page.getByTestId('widget-options-button').click();
+ await page.getByRole('menuitem', { name: 'Rename' }).click();
+ await page.getByRole('dialog', { name: 'Rename widget' }).getByLabel('Name').fill(`${widgetName} renamed`);
+ await page.getByTestId('widget-name-submit-button').click();
+ await expect(page.getByTestId('widget-switcher')).toHaveValue(`${widgetName} renamed`);
+ await expect(page.getByText('Saved', { exact: true })).toBeVisible();
+
+ const [saved] = await widgets.listEventWidgets(event.eventId);
+ expect(saved.settings).toEqual({});
+ await widgets.dispose();
+ });
+
+ test('a ticket deleted while it is being added to a widget is refused with the reason', async ({ authedPage: page, api, account, playwright }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const categories = await api.listProductCategories(event.eventId);
+ const vip = await api.createProduct(event.eventId, {
+ title: 'VIP Pass',
+ product_type: 'TICKET',
+ type: 'FREE',
+ product_category_id: categories[0].id,
+ prices: [{ price: 0 }],
+ });
+ const widgetName = uniqueName('Tickets');
+ await api.createEventWidget(event.eventId, { name: widgetName, type: 'TICKETS', settings: {} });
+ const widgets = await WidgetApi.create(playwright, account.token);
+
+ await page.goto(`/manage/event/${event.eventId}/widget`);
+ await page.getByTestId('widget-switcher').click();
+ await page.getByRole('option', { name: widgetName }).click();
+ await page.getByTestId('widget-group-tickets').click();
+ await page.getByRole('combobox', { name: 'Only these tickets' }).fill('VIP');
+ await page.keyboard.press('ArrowDown');
+ await page.keyboard.press('Enter');
+ await expect(page.getByTestId('widget-group-tickets')).toContainText('Selected tickets');
+ await expect(page.getByText('Unsaved changes')).toBeVisible();
+
+ await widgets.deleteProduct(event.eventId, vip.id);
+ await page.getByTestId('widget-save-button').click();
+ await expect(page.getByText('One or more selected products do not belong to this event.')).toBeVisible();
+ await expect(page.getByText('Unsaved changes')).toBeVisible();
+
+ const [saved] = await widgets.listEventWidgets(event.eventId);
+ expect(saved.settings).toEqual({});
+ await widgets.dispose();
+ });
+
+ test('organizers embed their events list from the organizer area', async ({ authedPage: page, api }) => {
+ const organizer = await api.createOrganizer(uniqueName('E2E Events Widget Org'));
+ const event = await createLiveEventWithFreeTicket(api, organizer.id);
+
+ await page.goto(`/manage/organizer/${organizer.id}`);
await page.getByRole('link', { name: 'Widget Embed' }).click();
await expect(page.getByRole('heading', { name: 'Events Widget' })).toBeVisible();
@@ -112,7 +184,7 @@ test.describe('widget editor', () => {
const preview = page.locator('iframe[title="Widget preview"]').first().contentFrame();
await expect(preview.locator('ul[data-layout="compact"]')).toBeVisible();
await expect(preview.getByText(event.title)).toBeVisible();
- await expect(page.getByTestId('widget-embed-code')).toContainText(`data-hievents-organizer-id="${account.organizerId}"`);
+ await expect(page.getByTestId('widget-embed-code')).toContainText(`data-hievents-organizer-id="${organizer.id}"`);
await expect(page.getByTestId('widget-embed-code')).toContainText('data-hievents-layout="compact"');
await preview.getByTestId('widget-event-item').filter({ hasText: event.title }).click();
diff --git a/e2e/tests/widget/widget-host.spec.ts b/e2e/tests/widget/widget-host.spec.ts
index 738aaf05c7..5917b6c98c 100644
--- a/e2e/tests/widget/widget-host.spec.ts
+++ b/e2e/tests/widget/widget-host.spec.ts
@@ -13,6 +13,7 @@ import {
} from '../../pages/embedded-widget';
import { createFreshOrganizer, createLiveEventWithFreeTicket, createReservedOrder } from '../../api/factory';
import { uniqueCode, uniqueEmail } from '../../utils/unique';
+import { waitForHydration } from '../../utils/hydration';
type HostWindow = Window & { hieventsLog: { name: string; detail: Record }[] };
@@ -83,6 +84,16 @@ test.describe('widget on a host page', () => {
await expect(widget.getByRole('heading', { name: 'Second Ticket' })).toBeVisible();
await expect(widget.getByRole('heading', { name: event.productTitle })).toHaveCount(0);
+ await page.evaluate((productId) => {
+ const element = document.querySelector('hievents-widget')!;
+ element.style.display = 'none';
+ element.setAttribute('products', String(productId));
+ }, event.productId);
+ await expect(widgetIframe(page)).toHaveCount(1);
+ await expect(widgetIframe(page)).toHaveAttribute('src', new RegExp(`Products=${event.productId}(&|$)`));
+ expect(await page.evaluate(() => document.querySelector('hievents-widget')!.style.display)).toBe('none');
+ await page.evaluate(() => { document.querySelector('hievents-widget')!.style.display = ''; });
+
await page.evaluate((src) => new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
@@ -188,6 +199,87 @@ test.describe('widget on a host page', () => {
.toBe(1);
});
+ test('switching the custom element from a button to tickets shows the ticket widget', async ({ page, api, account, baseURL }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ await openEmbedHost(page, baseURL!, ` `);
+ await expect(page.getByRole('button', { name: 'Book now' })).toBeVisible();
+
+ await page.evaluate(() => document.querySelector('hievents-widget')!.removeAttribute('widget-type'));
+
+ await expect(page.getByRole('button', { name: 'Book now' })).toHaveCount(0);
+ const widget = await resolveWidgetFrame(page);
+ await expect(widget.getByRole('heading', { name: event.productTitle })).toBeVisible();
+ await expect(widgetIframe(page)).toBeVisible();
+ expect((await widgetIframe(page).boundingBox())!.height).toBeGreaterThan(100);
+ });
+
+ test('the button keeps its colours on a host page that blocks inline styles', async ({ page, api, account, baseURL }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const blockedStyles: string[] = [];
+ page.on('console', (message) => {
+ if (message.text().includes('style-src')) blockedStyles.push(message.text());
+ });
+
+ await openStrictCspHost(page, baseURL!, widgetMarkup({
+ id: String(event.eventId),
+ 'widget-type': 'button',
+ 'button-text': 'Book now',
+ 'secondary-color': '#0f766e',
+ }));
+
+ const button = page.getByRole('button', { name: 'Book now' });
+ await expect(button).toBeVisible();
+ await expect(button).toHaveCSS('background-color', 'rgb(15, 118, 110)');
+ await expect(button).toHaveCSS('min-height', '44px');
+ expect(blockedStyles).toEqual([]);
+ });
+
+ test('with autoresize off the widget fills the height the host gives it, and host iframe rules still win', async ({ page, api, account, baseURL }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const fixedHeightWidget = `${widgetMarkup({ id: String(event.eventId), autoresize: 'false' }).replace('class="hievents-widget"', 'class="hievents-widget booking"')}`;
+
+ await openEmbedHost(page, baseURL!, fixedHeightWidget);
+ await expect(widgetIframe(page)).toBeVisible();
+ expect(Math.round((await widgetIframe(page).boundingBox())!.height)).toBe(500);
+
+ await page.unrouteAll();
+ await openEmbedHost(page, baseURL!, `${fixedHeightWidget}`);
+ await expect(widgetIframe(page)).toBeVisible();
+ expect(Math.round((await widgetIframe(page).boundingBox())!.height)).toBe(300);
+ });
+
+ test('a missing event or organizer shows its message at once, without hydration errors', async ({ page }) => {
+ const pageErrors: string[] = [];
+ page.on('pageerror', (error) => pageErrors.push(error.message));
+
+ await page.goto('/widget/999999999');
+ await waitForHydration(page, 'main.hi-widget-tokens');
+ await expect(page.getByText('This event is not available')).toBeVisible();
+ await expect(page.locator('.hi-widget-loading')).toHaveCount(0);
+ expect(pageErrors).toEqual([]);
+
+ await page.goto('/widget/organizer/999999999');
+ await waitForHydration(page, 'main.hi-widget-tokens');
+ await expect(page.getByText('These events are not available')).toBeVisible({ timeout: 2_000 });
+ await expect(page.locator('.hi-widget-loading')).toHaveCount(0, { timeout: 2_000 });
+
+ const lineupRequests: string[] = [];
+ page.on('request', (request) => {
+ if (request.url().includes('/events/999999999/lineup')) {
+ lineupRequests.push(request.url());
+ }
+ });
+ for (const path of ['/widget/999999999/lineup', '/widget/999999999/schedule']) {
+ await page.goto(path);
+ await waitForHydration(page, 'main.hi-widget-tokens');
+ await expect(page.getByText('This event is not available')).toBeVisible();
+ await expect(page.locator('.hi-widget-loading')).toHaveCount(0);
+ }
+ expect(lineupRequests).toEqual([]);
+
+ expect(pageErrors).toEqual([]);
+ });
+
test('widget routes render their content on the server', { tag: '@smoke' }, async ({ request, api }) => {
const organizer = await createFreshOrganizer(api);
const event = await createLiveEventWithFreeTicket(api, organizer.id);
@@ -201,6 +293,16 @@ test.describe('widget on a host page', () => {
});
});
+async function openStrictCspHost(page: import('@playwright/test').Page, baseURL: string, body: string) {
+ await page.context().grantPermissions(['local-network-access'], { origin: new URL(EMBED_HOST_URL).origin });
+ await page.route(`${EMBED_HOST_URL}**`, (route) => route.fulfill({
+ contentType: 'text/html',
+ headers: { 'Content-Security-Policy': "style-src 'self'" },
+ body: `Strict host ${body}`,
+ }));
+ await page.goto(EMBED_HOST_URL);
+}
+
async function resolveWidgetFrameAfterOpen(page: import('@playwright/test').Page, baseURL: string, eventId: number) {
await openEmbedHost(page, baseURL, widgetMarkup({ id: String(eventId) }));
return resolveWidgetFrame(page);
diff --git a/e2e/tests/widget/widget-modes.spec.ts b/e2e/tests/widget/widget-modes.spec.ts
index 91e0c83e51..a8c77d7cca 100644
--- a/e2e/tests/widget/widget-modes.spec.ts
+++ b/e2e/tests/widget/widget-modes.spec.ts
@@ -7,8 +7,26 @@ import {
resolveWidgetFrame,
widgetMarkup,
} from '../../pages/embedded-widget';
-import { createFreshOrganizer, createLiveEventWithFreeTicket, createLiveEventWithPaidTicket, createPastEventWithCoverImage } from '../../api/factory';
-import { uniqueEmail, uniqueName } from '../../utils/unique';
+import {
+ createFreshOrganizer,
+ createLiveEventWithFreeTicket,
+ createLiveEventWithPaidTicket,
+ createPastEventWithCoverImage,
+ enableOfflinePayments,
+} from '../../api/factory';
+import { uniqueCode, uniqueEmail, uniqueName } from '../../utils/unique';
+import { WidgetApi } from './widget-api';
+
+type HostOrdersWindow = Window & { hieventsOrders: unknown[] };
+
+const recordHostOrders = (page: import('@playwright/test').Page) => page.evaluate(() => {
+ const log: unknown[] = [];
+ (window as unknown as HostOrdersWindow).hieventsOrders = log;
+ window.addEventListener('hievents:order-completed', (event) => log.push((event as CustomEvent).detail));
+});
+
+const hostOrders = (page: import('@playwright/test').Page) =>
+ page.evaluate(() => (window as unknown as HostOrdersWindow).hieventsOrders);
test.describe('widget modes', () => {
test('the ticket widget shows the chosen sections and only the selected tickets', async ({ page, api, account, baseURL }) => {
@@ -38,6 +56,7 @@ test.describe('widget modes', () => {
test('a promo code in the host page link is applied in the widget', async ({ page, api, account, baseURL }) => {
const event = await createLiveEventWithPaidTicket(api, account.organizerId, 20);
+ await api.updateEventSettings(event.eventId, { pass_platform_fee_to_buyer: false });
const code = uniqueName('HOSTPROMO').replace(/[^A-Za-z0-9]/g, '').slice(0, 20).toUpperCase();
await api.createPromoCode(event.eventId, { code, discount_type: 'PERCENTAGE', discount: 10, applicable_product_ids: [] });
@@ -81,6 +100,112 @@ test.describe('widget modes', () => {
.toEqual([expect.objectContaining({ eventId: String(event.eventId), quantity: 1, status: 'COMPLETED' })]);
});
+ test('a paid order through the ticket button tells the host page its total and currency', async ({ page, api, account, baseURL }) => {
+ const event = await createLiveEventWithPaidTicket(api, account.organizerId, 20);
+ await api.updateEventSettings(event.eventId, { pass_platform_fee_to_buyer: false });
+ await enableOfflinePayments(api, event.eventId);
+ const buyer = { firstName: 'Paid', lastName: 'Button', email: uniqueEmail('paid-button') };
+
+ await openEmbedHost(page, baseURL!, widgetMarkup({ id: String(event.eventId), 'widget-type': 'button', 'button-text': 'Book now' }));
+ await recordHostOrders(page);
+
+ await page.getByRole('button', { name: 'Book now' }).click();
+ const widget = await resolveWidgetFrame(page);
+ await setWidgetQuantity(widget, 1);
+ await widget.getByTestId('checkout-continue-button').click();
+ const checkoutFrame = await resolveCheckoutFrame(page);
+ await checkoutFrame.waitForURL(/\/checkout\/\d+\/[^/]+\/details/);
+ const checkout = new CheckoutPage(page, checkoutFrame);
+ await checkout.fillOrderDetails(buyer);
+ await checkout.fillFirstAttendee(buyer);
+ await checkout.continueToPayment();
+ await checkout.chooseOfflinePayment();
+
+ await expect.poll(() => hostOrders(page)).toContainEqual(expect.objectContaining({
+ eventId: String(event.eventId),
+ currency: 'USD',
+ total: 20,
+ quantity: 1,
+ status: 'AWAITING_OFFLINE_PAYMENT',
+ }));
+ const [order] = await api.listOrders(event.eventId);
+ expect(Number(order.total_gross)).toBe(20);
+ });
+
+ test('a saved events widget credits the host page affiliate and counts the order it sold', async ({ page, api, account, baseURL, playwright }) => {
+ const organizer = await createFreshOrganizer(api);
+ const event = await createLiveEventWithFreeTicket(api, organizer.id);
+ const affiliateCode = uniqueCode('EVENTSAFF');
+ await api.createAffiliate(event.eventId, { name: 'Events partner', code: affiliateCode });
+ const widgets = await WidgetApi.create(playwright, account.token);
+ const saved = await widgets.createOrganizerWidget(organizer.id, { name: 'Listing', type: 'EVENTS', settings: { layout: 'list' } });
+ const buyer = { firstName: 'Listing', lastName: 'Buyer', email: uniqueEmail('events-widget') };
+
+ await openEmbedHost(
+ page,
+ baseURL!,
+ ``,
+ `?aff=${affiliateCode}`,
+ );
+ const list = await resolveWidgetFrame(page, 'Hi.Events Events');
+ await list.getByTestId('widget-event-item').filter({ hasText: event.title }).click();
+ const widget = await resolveWidgetFrame(page);
+ await setWidgetQuantity(widget, 1);
+ await widget.getByTestId('checkout-continue-button').click();
+ const checkoutFrame = await resolveCheckoutFrame(page);
+ await checkoutFrame.waitForURL(/\/checkout\/\d+\/[^/]+\/details/);
+ const checkout = new CheckoutPage(page, checkoutFrame);
+ await checkout.fillOrderDetails(buyer);
+ await checkout.fillFirstAttendee(buyer);
+ await checkout.completeFreeOrder();
+ await expect(checkoutFrame.getByText(`You're going to ${event.title}`)).toBeVisible();
+
+ await expect.poll(async () => (await api.listAffiliates(event.eventId)).find((affiliate) => affiliate.code === affiliateCode)?.total_sales)
+ .toBe(1);
+ await expect.poll(async () => (await widgets.listOrganizerWidgets(organizer.id)).find((candidate) => candidate.id === saved.id)?.stats.orders)
+ .toBe(1);
+ await widgets.dispose();
+ });
+
+ test('an events widget that opens event pages passes on the host page affiliate, promo code and campaign', async ({ page, api, baseURL }) => {
+ const organizer = await createFreshOrganizer(api);
+ const event = await createLiveEventWithFreeTicket(api, organizer.id);
+ const affiliateCode = uniqueCode('PAGEAFF');
+ await api.createAffiliate(event.eventId, { name: 'Page partner', code: affiliateCode });
+ const promoCode = uniqueCode('PAGEPROMO');
+ await api.createPromoCode(event.eventId, { code: promoCode, discount_type: 'PERCENTAGE', discount: 10, applicable_product_ids: [] });
+ const buyer = { firstName: 'Page', lastName: 'Buyer', email: uniqueEmail('events-page') };
+
+ await openEmbedHost(
+ page,
+ baseURL!,
+ ``,
+ `?aff=${affiliateCode}&promo_code=${promoCode}&utm_source=newsletter`,
+ );
+ const list = await resolveWidgetFrame(page, 'Hi.Events Events');
+ const [eventPage] = await Promise.all([
+ page.context().waitForEvent('page'),
+ list.getByTestId('widget-event-item').filter({ hasText: event.title }).click(),
+ ]);
+ await eventPage.waitForLoadState('networkidle');
+
+ const opened = new URL(eventPage.url());
+ expect(opened.pathname).toContain(`/event/${event.eventId}/`);
+ expect(opened.searchParams.get('aff')).toBe(affiliateCode);
+ expect(opened.searchParams.get('promo_code')).toBe(promoCode);
+ expect(opened.searchParams.get('utm_source')).toBe('newsletter');
+
+ const checkout = new CheckoutPage(eventPage);
+ await checkout.setFirstProductQuantity(1);
+ await checkout.continueToCheckout();
+ await checkout.fillOrderDetails(buyer);
+ await checkout.fillFirstAttendee(buyer);
+ await checkout.completeFreeOrder();
+
+ await expect.poll(async () => (await api.listAffiliates(event.eventId)).find((affiliate) => affiliate.code === affiliateCode)?.total_sales)
+ .toBe(1);
+ });
+
test('the events widget lists an organizer’s events and opens tickets in a pop-up', async ({ page, api, baseURL }) => {
const organizer = await createFreshOrganizer(api);
const event = await createLiveEventWithFreeTicket(api, organizer.id);
diff --git a/e2e/tests/widget/widget-playground.spec.ts b/e2e/tests/widget/widget-playground.spec.ts
index db97d0fd52..097e254b4a 100644
--- a/e2e/tests/widget/widget-playground.spec.ts
+++ b/e2e/tests/widget/widget-playground.spec.ts
@@ -1,6 +1,7 @@
import { test, expect } from '../../fixtures';
import { setWidgetQuantity } from '../../pages/checkout.page';
import { createLiveEventWithFreeTicket } from '../../api/factory';
+import { uniqueName } from '../../utils/unique';
test.describe('widget playground', () => {
test('the playground renders a widget with the configured settings', async ({ page, api, account }) => {
@@ -26,14 +27,15 @@ test.describe('widget playground', () => {
await expect(page.locator('#message-log')).toContainText('hievents:open-checkout');
});
- test('the playground embeds an organizer events list as a custom element', async ({ page, api, account }) => {
- const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ test('the playground embeds an organizer events list as a custom element', async ({ page, api }) => {
+ const organizer = await api.createOrganizer(uniqueName('E2E Events Widget Org'));
+ const event = await createLiveEventWithFreeTicket(api, organizer.id);
- await page.goto(`/widget-test?type=events&organizerId=${account.organizerId}&markup=element&layout=compact`);
+ await page.goto(`/widget-test?type=events&organizerId=${organizer.id}&markup=element&layout=compact`);
const list = page.frameLocator('iframe[title="Hi.Events Events"]');
await expect(list.getByText(event.title)).toBeVisible();
await expect(page.locator('#embed-code')).toContainText(` {
+ test('widget pages ask search engines not to index them on their own', async ({ request, api, account }) => {
+ const event = await createDraftEventWithTicket(api, account.organizerId);
+ const widgetPaths = [
+ `/widget/${event.eventId}`,
+ `/widget/${event.eventId}/lineup`,
+ `/widget/${event.eventId}/schedule`,
+ `/widget/organizer/${account.organizerId}`,
+ ];
+
+ for (const path of widgetPaths) {
+ const response = await request.get(path);
+ expect(response.headers()['x-robots-tag'], path).toBe('noindex, indexifembedded');
+ }
+
+ const homepage = await request.get(`/event/${event.eventId}/${event.slug}`);
+ expect(homepage.headers()['x-robots-tag']).toBeUndefined();
+ });
+});
diff --git a/e2e/utils/unique.ts b/e2e/utils/unique.ts
index 80bbe7480b..6bc902182e 100644
--- a/e2e/utils/unique.ts
+++ b/e2e/utils/unique.ts
@@ -14,5 +14,7 @@ export const uniqueName = (prefix: string): string => `${prefix} ${token()}`;
export const uniqueCode = (prefix = 'E2E'): string =>
`${prefix}${token().replace(/-/g, '').toUpperCase()}`.slice(0, 30);
-export const uniqueShort = (prefix: string): string =>
- `${prefix} ${Math.random().toString(36).slice(2, 8)}`;
+const randomLetters = (length: number): string =>
+ Array.from({ length }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join('');
+
+export const uniqueShort = (prefix: string): string => `${prefix} ${randomLetters(6)}`;
diff --git a/frontend/package.json b/frontend/package.json
index 896b12f9f5..98e47f549c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "hievents-frontend",
"private": true,
- "version": "2.0.0-beta",
+ "version": "2.0.0-rc.3",
"type": "module",
"scripts": {
"dev:csr": "vite --port 5678 --host 0.0.0.0",
diff --git a/frontend/public/widget.js b/frontend/public/widget.js
index 6f19f46d65..a8f3d4f947 100644
--- a/frontend/public/widget.js
+++ b/frontend/public/widget.js
@@ -578,6 +578,33 @@
: fallback
);
+ const constructedSheet = (cssText) => {
+ try {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(cssText);
+ return sheet;
+ } catch (e) {
+ return null;
+ }
+ };
+
+ const FIXED_HEIGHT_CSS = ':where(.hievents-widget[data-hievents-autoresize="false"] > iframe, '
+ + 'hievents-widget[data-hievents-autoresize="false"] > iframe, hievents-widget[autoresize="false"] > iframe) { height: 100%; }';
+ let fixedHeightStylesAdded = false;
+
+ const addFixedHeightStyles = () => {
+ if (fixedHeightStylesAdded) return;
+ fixedHeightStylesAdded = true;
+ const sheet = constructedSheet(FIXED_HEIGHT_CSS);
+ if (sheet && 'adoptedStyleSheets' in document) {
+ document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet);
+ return;
+ }
+ const style = document.createElement('style');
+ style.textContent = FIXED_HEIGHT_CSS;
+ document.head.appendChild(style);
+ };
+
const renderButton = (element, attributes, instance) => {
const root = element.shadowRoot || element.attachShadow({ mode: 'open' });
const button = document.createElement('button');
@@ -588,8 +615,7 @@
const color = cssColor(attributes['secondary-text-color'], '#ffffff');
const radiusValue = parseFloat(attributes.radius);
const radius = Number.isFinite(radiusValue) ? `${Math.max(0, Math.min(radiusValue, 32))}px` : '10px';
- const style = document.createElement('style');
- style.textContent = `
+ const css = `
:host { display: inline-block; }
button {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
@@ -604,7 +630,14 @@
button:focus-visible { outline: 2px solid ${background}; outline-offset: 3px; }
`;
root.innerHTML = '';
- root.appendChild(style);
+ const sheet = constructedSheet(css);
+ if (sheet && 'adoptedStyleSheets' in root) {
+ root.adoptedStyleSheets = [sheet];
+ } else {
+ const style = document.createElement('style');
+ style.textContent = css;
+ root.appendChild(style);
+ }
root.appendChild(button);
button.addEventListener('click', safe(() => {
@@ -652,6 +685,11 @@
if (element.tagName === 'HIEVENTS-WIDGET' && !element.style.display) {
element.style.display = 'block';
+ instance.setDisplay = true;
+ }
+
+ if (!instance.autoResize) {
+ addFixedHeightStyles();
}
const iframe = document.createElement('iframe');
@@ -678,7 +716,13 @@
try { instance.iframe.remove(); } catch (e) { /* noop */ }
}
if (instance.button && element.shadowRoot) {
- element.shadowRoot.innerHTML = '';
+ element.shadowRoot.replaceChildren(document.createElement('slot'));
+ if ('adoptedStyleSheets' in element.shadowRoot) {
+ element.shadowRoot.adoptedStyleSheets = [];
+ }
+ }
+ if (instance.setDisplay && element.style.display === 'block') {
+ element.style.display = '';
}
instances.delete(iframeId);
}
diff --git a/frontend/server.js b/frontend/server.js
index a75834f81b..b402cbbaff 100644
--- a/frontend/server.js
+++ b/frontend/server.js
@@ -155,18 +155,31 @@ Sitemap: ${frontendUrl}/sitemap.xml
});
const frameProtectedPathPattern = /^\/(auth|manage|account|profile|settings|welcome|admin|sso)(\/|$)/;
+ const homepagePreviewPathPattern = /^\/(event|organizer)\/[^/]+\/preview\/*$/;
+ const widgetPathPattern = /^\/widget\//;
const credentialInUrlPathPattern = /^\/(sso\/callback|auth\/accept-invitation\/)/;
const ssoCallbackPathPattern = /^\/sso\/callback\/?$/;
- const requestPathOf = (req) => req.originalUrl.replace(base, "/").split("?")[0];
+ const requestPathOf = (req) => {
+ const path = req.originalUrl.replace(base, "/").split("?")[0];
+ try {
+ return decodeURIComponent(path).toLowerCase();
+ } catch {
+ return path.toLowerCase();
+ }
+ };
app.use("*", (req, res, next) => {
const requestPath = requestPathOf(req);
- if (frameProtectedPathPattern.test(requestPath)) {
+ if (frameProtectedPathPattern.test(requestPath) || homepagePreviewPathPattern.test(requestPath)) {
res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
res.setHeader("X-Frame-Options", "SAMEORIGIN");
}
+ if (widgetPathPattern.test(requestPath)) {
+ res.setHeader("X-Robots-Tag", "noindex, indexifembedded");
+ }
+
if (credentialInUrlPathPattern.test(requestPath)) {
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("Cache-Control", "no-store");
@@ -191,7 +204,7 @@ Sitemap: ${frontendUrl}/sitemap.xml
render = (await dynamicImport(path.join(__dirname, "./dist/server/entry.server.js"))).render;
}
- const { appHtml, dehydratedState, helmetContext, themeColors, statusCode, renderErrors } = await render(
+ const { appHtml, dehydratedState, helmetContext, themeColors, htmlLang, statusCode, renderErrors } = await render(
{
req,
res,
@@ -237,6 +250,7 @@ Sitemap: ${frontendUrl}/sitemap.xml
}
const html = template
+ .replace('', () => ``)
.replace("", () => headSnippets.join("\n"))
.replace("", () => appHtml)
.replace("", () => ``)
diff --git a/frontend/src/api/authErrorRedirect.ts b/frontend/src/api/authErrorRedirect.ts
new file mode 100644
index 0000000000..dcb0d6a361
--- /dev/null
+++ b/frontend/src/api/authErrorRedirect.ts
@@ -0,0 +1,55 @@
+export const LOGIN_PATH = "/auth/login";
+export const TWO_FACTOR_SETUP_PATH = "/auth/two-factor-setup";
+const ACCOUNT_DELETION_PATH = "/settings/account#delete-account";
+const ACCESS_ERROR_CODES = ['FEATURE_UNAVAILABLE', 'ACCESS_RESTRICTED', 'PERMISSION_REQUIRED', 'READ_ONLY_ROLE', 'IMPERSONATION_NOT_ALLOWED'];
+
+const ALLOWED_UNAUTHENTICATED_PATHS = [
+ 'auth/login',
+ 'accept-invitation',
+ 'register',
+ 'forgot-password',
+ 'auth',
+ 'account/payment',
+ 'checkout',
+ 'box-office',
+ '/event/',
+ 'print',
+ '/order/',
+ 'widget',
+ '/product/',
+ 'check-in',
+ '/events/',
+ 'my-tickets',
+ 'emails/unsubscribe',
+ 'sso/callback',
+];
+
+export type AuthErrorAction =
+ | { type: 'none' }
+ | { type: 'redirect', to: string }
+ | { type: 'login' };
+
+const NONE: AuthErrorAction = {type: 'none'};
+
+const isPublicPath = (path: string) => !path.startsWith('/manage/')
+ && ALLOWED_UNAUTHENTICATED_PATHS.some(allowed => path.includes(allowed));
+
+export const resolveAuthErrorAction = (status: number, errorCode: string | undefined, currentPath: string): AuthErrorAction => {
+ if (status === 403 && errorCode === 'ACCOUNT_PENDING_DELETION') {
+ return currentPath.startsWith('/settings') ? NONE : {type: 'redirect', to: ACCOUNT_DELETION_PATH};
+ }
+
+ if (status === 403 && errorCode === 'TWO_FACTOR_SETUP_REQUIRED') {
+ return currentPath.startsWith(TWO_FACTOR_SETUP_PATH) ? NONE : {type: 'redirect', to: TWO_FACTOR_SETUP_PATH};
+ }
+
+ if (status === 403 && errorCode && ACCESS_ERROR_CODES.includes(errorCode)) {
+ return NONE;
+ }
+
+ if ((status === 401 || status === 403) && !isPublicPath(currentPath)) {
+ return {type: 'login'};
+ }
+
+ return NONE;
+};
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 9d84771d0d..437833561f 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -3,37 +3,15 @@ import {isSsr} from "../utilites/helpers.ts";
import {getConfig} from "../utilites/config.ts";
import {applySsrRequestHeaders} from "./ssrRequestHeaders.ts";
import {queueSilentReauthAfterExpiry, setAuthNotice} from "../utilites/ssoFlow.ts";
+import {LOGIN_PATH, resolveAuthErrorAction, TWO_FACTOR_SETUP_PATH} from "./authErrorRedirect.ts";
+
+export {TWO_FACTOR_SETUP_PATH};
const BASE_URL = isSsr()
? getConfig('VITE_API_URL_SERVER')
: getConfig('VITE_API_URL_CLIENT');
-const LOGIN_PATH = "/auth/login";
-export const TWO_FACTOR_SETUP_PATH = "/auth/two-factor-setup";
export const SECURITY_SETTINGS_PATH = "/settings/security";
const PREVIOUS_URL_KEY = 'previous_url';
-const ACCESS_ERROR_CODES = ['FEATURE_UNAVAILABLE', 'ACCESS_RESTRICTED', 'PERMISSION_REQUIRED', 'READ_ONLY_ROLE', 'IMPERSONATION_NOT_ALLOWED'];
-
-// todo - This isn't scalable, we need to better way to manage this
-const ALLOWED_UNAUTHENTICATED_PATHS = [
- 'auth/login',
- 'accept-invitation',
- 'register',
- 'forgot-password',
- 'auth',
- 'account/payment',
- 'checkout',
- 'box-office',
- '/event/',
- 'print',
- '/order/',
- 'widget',
- '/product/',
- 'check-in',
- '/events/',
- 'my-tickets',
- 'emails/unsubscribe',
- 'sso/callback',
-];
export const api = axios.create({
baseURL: BASE_URL,
@@ -51,32 +29,15 @@ api.interceptors.response.use(
if (!error.response) {
return Promise.reject(error);
}
- const { status } = error.response;
- const currentPath = window?.location.pathname;
- const isAllowedUnauthenticatedPath = ALLOWED_UNAUTHENTICATED_PATHS.some(path => currentPath.includes(path));
- const isManageEventPath = currentPath.startsWith('/manage/event/');
- const isAuthError = status === 401 || status === 403;
-
- if (status === 403 && error.response.data?.error_code === 'ACCOUNT_PENDING_DELETION') {
- if (!currentPath.startsWith('/settings/account')) {
- window?.location?.replace('/settings/account#delete-account');
- }
- return Promise.reject(error);
- }
+ const {status, data} = error.response;
+ const action = resolveAuthErrorAction(status, data?.error_code, window?.location.pathname);
- if (status === 403 && error.response.data?.error_code === 'TWO_FACTOR_SETUP_REQUIRED') {
- if (!currentPath.startsWith(TWO_FACTOR_SETUP_PATH)) {
- window?.location?.replace(TWO_FACTOR_SETUP_PATH);
- }
- return Promise.reject(error);
- }
-
- if (status === 403 && ACCESS_ERROR_CODES.includes(error.response.data?.error_code)) {
- return Promise.reject(error);
+ if (action.type === 'redirect') {
+ window?.location?.replace(action.to);
}
- if (isAuthError && (!isAllowedUnauthenticatedPath || isManageEventPath)) {
- if (error.response.data?.error_code === 'SSO_REQUIRED') {
+ if (action.type === 'login') {
+ if (data?.error_code === 'SSO_REQUIRED') {
setAuthNotice('SSO_REQUIRED');
} else if (status === 401) {
queueSilentReauthAfterExpiry();
diff --git a/frontend/src/api/lineup.client.ts b/frontend/src/api/lineup.client.ts
index 0297408e6d..a13f96e1eb 100644
--- a/frontend/src/api/lineup.client.ts
+++ b/frontend/src/api/lineup.client.ts
@@ -50,6 +50,7 @@ export interface LineupEntry {
role_label: string | null;
is_hidden?: boolean;
sort_order?: number;
+ schedule_days?: string[];
profile: Profile;
}
diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts
index 5c3587c7da..0ec68fa0f7 100644
--- a/frontend/src/api/public-client.ts
+++ b/frontend/src/api/public-client.ts
@@ -23,7 +23,7 @@ publicApi.interceptors.request.use((config) => {
config.baseURL = `${baseUrl}/public`;
- const boxOfficeShortId = config.url?.match(/\/box-offices\/([^/?#]+)\//)?.[1];
+ const boxOfficeShortId = config.url?.match(/\/box-offices\/([^/?#]+)(?:[/?#]|$)/)?.[1];
if (boxOfficeShortId && !config.url?.endsWith('/sessions')) {
const token = getBoxOfficeSessionToken(boxOfficeShortId);
if (token) {
diff --git a/frontend/src/api/sso.client.ts b/frontend/src/api/sso.client.ts
index 1d249b0d15..8689833597 100644
--- a/frontend/src/api/sso.client.ts
+++ b/frontend/src/api/sso.client.ts
@@ -3,10 +3,12 @@ import {
GenericDataResponse,
IdentityConfirmation,
IdParam,
+ LinkableSsoConnection,
LoginResponse,
SsoExchangeResponse,
SsoIntent,
SsoProvidersResponse,
+ SsoSignInMethods,
SsoSignupRequest,
SsoStartResponse,
UserIdentity,
@@ -61,9 +63,15 @@ export const ssoClient = {
return response.data;
},
- identities: async () => {
- const response = await api.get>('users/me/sso-identities');
- return response.data.data;
+ identities: async (): Promise => {
+ const response = await api.get & {
+ meta?: { linkable_connections?: LinkableSsoConnection[] }
+ }>('users/me/sso-identities');
+
+ return {
+ identities: response.data.data,
+ linkableConnections: response.data.meta?.linkable_connections ?? [],
+ };
},
startLink: async (provider: string, request: IdentityConfirmation & { return_path?: string }) => {
diff --git a/frontend/src/api/user.client.ts b/frontend/src/api/user.client.ts
index 0798b653c5..fa602be71f 100644
--- a/frontend/src/api/user.client.ts
+++ b/frontend/src/api/user.client.ts
@@ -19,7 +19,7 @@ export interface UserMeRequest {
export interface UpdateUserRequest extends Partial {
first_name: string;
last_name: string;
- role: UserRole;
+ role?: UserRole;
status?: string;
}
diff --git a/frontend/src/components/common/CalendarOptionsPopover/index.tsx b/frontend/src/components/common/CalendarOptionsPopover/index.tsx
index 0e832c4a4f..bd544dbf59 100644
--- a/frontend/src/components/common/CalendarOptionsPopover/index.tsx
+++ b/frontend/src/components/common/CalendarOptionsPopover/index.tsx
@@ -1,51 +1,36 @@
import {t} from "@lingui/macro";
-import {Button, Popover, Stack, Text} from "@mantine/core";
+import {Menu} from "@mantine/core";
import {IconBrandGoogle, IconDownload} from "@tabler/icons-react";
import {Event, EventOccurrence} from "../../../types.ts";
import {CalendarEntry, calendarEntryForEvent, createGoogleCalendarUrl, downloadICSFile} from "../../../utilites/calendar.ts";
-import {cloneElement, ReactElement, useState} from "react";
+import {ReactElement} from "react";
-type CalendarOptionsPopoverProps = { children: ReactElement<{ onClick?: () => void }> }
+type CalendarOptionsPopoverProps = { children: ReactElement }
& ({ event: Event; occurrence?: EventOccurrence } | { entry: CalendarEntry });
export const CalendarOptionsPopover = (props: CalendarOptionsPopoverProps) => {
const entry = 'entry' in props ? props.entry : calendarEntryForEvent(props.event, props.occurrence);
- const [opened, setOpened] = useState(false);
return (
-
-
- {cloneElement(props.children, {onClick: () => setOpened(current => !current)})}
-
-
-
- {t`Choose calendar`}
-
-
-
-
-
+
);
};
diff --git a/frontend/src/components/common/ConfirmIdentity/ConfirmIdentityModal.tsx b/frontend/src/components/common/ConfirmIdentity/ConfirmIdentityModal.tsx
index f954cce1c7..274162850a 100644
--- a/frontend/src/components/common/ConfirmIdentity/ConfirmIdentityModal.tsx
+++ b/frontend/src/components/common/ConfirmIdentity/ConfirmIdentityModal.tsx
@@ -1,6 +1,6 @@
import {Button} from "@mantine/core";
import {t} from "@lingui/macro";
-import {FormEvent, ReactNode} from "react";
+import {FormEvent, ReactNode, useEffect, useRef} from "react";
import {IdentityConfirmation} from "../../../types.ts";
import {Modal, ModalFooter} from "../Modal";
import {ConfirmIdentityField, useIdentityConfirmation} from "./index.tsx";
@@ -28,18 +28,30 @@ export const ConfirmIdentityModal = ({
onClose,
}: ConfirmIdentityModalProps) => {
const identity = useIdentityConfirmation();
+ const formRef = useRef(null);
+ const wasSubmitting = useRef(isSubmitting);
+
+ useEffect(() => {
+ const settled = wasSubmitting.current && !isSubmitting;
+ wasSubmitting.current = isSubmitting;
+ const focusWasLost = !document.activeElement || document.activeElement === document.body;
+
+ if (settled && focusWasLost) {
+ formRef.current?.querySelector('input:not(:disabled), button:not(:disabled)')?.focus();
+ }
+ }, [isSubmitting]);
const submit = (event: FormEvent) => {
event.preventDefault();
- if (identity.confirmation) {
+ if (identity.confirmation && !isSubmitting) {
onConfirm(identity.confirmation, identity.setError);
}
};
return (
-