From c7a90ff8b360fc1f74ea89d7282e62141b62cf67 Mon Sep 17 00:00:00 2001
From: max-1-max <201788345+max-1-max@users.noreply.github.com>
Date: Tue, 20 Oct 2026 09:21:32 +0100
Subject: [PATCH] Harden develop for v2.0.0-rc.3
Fixes, tests and decisions for everything in develop that is not yet in
main (team access, lineup and homepage sections, settings redesign, saved
widgets, first day of week, SSO, API keys, enterprise SSO in ee), plus the
app bugs found while auditing the help center.
Security
- Homepage previews only accept settings from the app's own parent frame and
can't be framed by other sites; frame protection matches any letter case
or URL encoding of the protected paths.
- Changing an enterprise SSO connection's issuer, client or endpoints needs a
new client secret, so the stored one is never sent to a new provider.
- Stopping impersonation invalidates the impersonation token.
- SSO-enforced members are refused before the 2FA prompt; platform
superadmins can't link, sign in through or confirm identity with an
account's company SSO.
- Saved widgets reject newly submitted ids that aren't on the event.
- Offline payment instructions need Manage payments and a session to change.
Money and capacity
- Webhook payloads keep full prices whoever triggers them.
- A second partial Stripe refund of the same amount goes through; a pending
refund stays Refund pending instead of failed; the refund email is sent
once Stripe confirms success.
- Reactivating a cancelled attendee checks ticket, shared and date capacity.
- Struck "was" prices use per-date overrides and the tax-inclusive figure;
the cart total rounds per line like checkout.
- The marked-as-paid email attaches the paid invoice.
Product decisions
- Version 2.0.0-rc.3.
- Date-only picks in end and expiry fields default to 23:59.
- SSO_BUTTONS_VISIBLE removed.
- Active members, the owner included, can link the account's enterprise
connection; Require SSO no longer signs out or revokes keys of members the
connection can't sign in.
- Licence lapse copy and ee/LICENCE describe what keeps working.
- The legacy attendee check_in API endpoint is deprecated.
Messaging, webhooks and config
- "Send as test" only ever emails the sender and sends immediately.
- Webhook deliveries are queued and retried; payloads too large to queue are
sent inline once.
- Queue batching and failed jobs default to pgsql; APP_CDN_URL is read via
config; the organizer attendee self-service default saves.
Plus lineup, schedule, homepage section, widget, settings and accessibility
fixes, a valid OpenAPI 3.1 reference, and translations for every locale.
Backend tests go from 3,259 to 3,553 and E2E tests from 601 to 784.
---
.claude/rules/backend/account-deletion.md | 1 +
.claude/rules/backend/sso.md | 2 +-
.claude/rules/backend/team-access.md | 3 +
.claude/rules/ee/enterprise-sso.md | 7 +-
.claude/rules/features/lineup.md | 3 +-
.claude/rules/features/widget.md | 4 +-
.github/workflows/e2e.yml | 27 +
.github/workflows/tests.yml | 31 +-
VERSION | 2 +-
backend/.env.example | 7 +-
backend/VERSION | 2 +-
.../app/Console/Commands/SsoStatusCommand.php | 38 +-
.../Commands/SsoUnlinkIdentityCommand.php | 2 +-
backend/app/DomainObjects/Enums/SsoIntent.php | 2 +-
.../ProductPriceDomainObject.php | 14 +
backend/app/Helper/Url.php | 6 +-
.../Admin/Users/StopImpersonationAction.php | 3 +
.../Attendees/CheckInAttendeeAction.php | 3 +
.../Actions/Auth/GetUserInvitationAction.php | 6 +-
.../Http/Actions/Auth/ResetPasswordAction.php | 2 +-
.../Auth/Sso/GetSsoIdentitiesAction.php | 16 +-
backend/app/Http/Actions/BaseAction.php | 5 +-
.../Questions/CreateQuestionAction.php | 2 +-
.../Actions/Questions/EditQuestionAction.php | 2 +-
.../TaxesAndFees/EditTaxOrFeeAction.php | 6 +-
.../BaseUpsertOrganizerWidgetAction.php | 25 +-
.../GenerateOccurrencesRequest.php | 1 +
.../UpdateEventSettingsRequest.php | 81 +-
.../Lineup/SortLineupEntriesRequest.php | 8 +-
.../Lineup/SortProfileImagesRequest.php | 8 +-
.../Lineup/UpsertScheduleItemRequest.php | 11 +-
.../Questions/UpsertQuestionRequest.php | 5 +-
.../Mail/Account/AdminAccessGrantedMail.php | 5 +
.../app/Mail/Attendee/AttendeeTicketMail.php | 23 +-
.../app/OpenApi/IntegrationApiReference.php | 74 +-
.../app/Providers/RouteServiceProvider.php | 20 +-
.../app/Providers/ScrambleServiceProvider.php | 17 +
.../AccountUserAccessGrantRepository.php | 21 +-
.../PersonalAccessTokenRepository.php | 7 +
.../app/Resources/Auth/InvitationResource.php | 17 +-
.../EventOccurrenceResource.php | 1 +
.../Lineup/EventLineupResourcePublic.php | 4 +-
.../Lineup/LineupEntryResourcePublic.php | 8 +
.../Product/ProductPriceResourcePublic.php | 2 +
backend/app/Resources/User/MeResource.php | 2 +-
.../app/Resources/Widget/WidgetResource.php | 2 +
.../Handlers/ApiKey/DeleteApiKeyHandler.php | 8 +-
.../Attendee/CheckInAttendeeHandler.php | 4 +
.../Attendee/PartialEditAttendeeHandler.php | 225 +-
.../Handlers/Auth/AcceptInvitationHandler.php | 12 +
.../Auth/DTO/InvitationDetailsDTO.php | 14 +
.../Handlers/Auth/GetInvitationHandler.php | 37 +
.../Auth/Sso/DTO/SsoSignInMethodsResult.php | 20 +
.../Auth/Sso/ExchangeSsoCodeHandler.php | 9 +-
.../Auth/Sso/GetSsoIdentitiesHandler.php | 19 +-
.../Auth/Sso/GetSsoProvidersHandler.php | 3 +-
.../Handlers/Auth/Sso/StartSsoHandler.php | 6 +
.../Handlers/Lineup/DTO/EventLineupDTO.php | 1 +
.../Handlers/Lineup/GetEventLineupHandler.php | 35 +
.../Handlers/Lineup/ImportLineupHandler.php | 5 +-
.../Handlers/Message/SendMessageHandler.php | 2 +-
.../Handlers/Order/GetOrderPublicHandler.php | 2 +
.../Payment/Stripe/RefundOrderHandler.php | 46 +-
.../PartialUpdateOrganizerSettingsHandler.php | 17 +-
.../Handlers/TaxAndFee/DTO/UpsertTaxDTO.php | 2 +-
.../Handlers/User/DTO/UpdateUserDTO.php | 4 +-
.../Handlers/User/UpdateMeHandler.php | 2 +-
.../Handlers/User/UpdateUserHandler.php | 30 +-
.../Handlers/Widget/UpsertWidgetHandler.php | 96 +-
.../Anonymizers/OrganizerAnonymizer.php | 14 +
.../Access/AdminAccessNotificationService.php | 8 +
.../app/Services/Domain/Auth/LoginService.php | 10 +-
.../Domain/Auth/Sso/SsoProviderRegistry.php | 5 -
.../Domain/Email/EmailTokenContextBuilder.php | 2 +-
.../Domain/Event/CreateEventService.php | 2 +-
.../Event/EventSpamCheckContentService.php | 168 +-
.../Domain/Event/EventSpamCheckService.php | 37 +
.../DTO/LineupHighlightSelectionDTO.php | 22 +
.../Lineup/EmailLineupHighlightsService.php | 59 +-
.../Domain/Lineup/LineupHighlightSelector.php | 69 +
.../Mail/SendEventEmailMessagesService.php | 11 +-
.../Domain/Mail/SendOrderDetailsService.php | 5 +-
.../TeamOrderNotificationRecipientService.php | 7 +-
.../Domain/Message/MessageDispatchService.php | 2 +-
.../Domain/Order/MarkOrderAsPaidService.php | 4 +-
.../ChargeRefundUpdatedHandler.php | 73 +-
.../StripePaymentIntentRefundService.php | 50 +-
.../StripeRefundExpiredOrderService.php | 12 +-
.../Domain/Product/ProductFilterService.php | 13 +-
.../Lock/TransactionLockService.php | 10 +
.../Webhook/WebhookDispatchService.php | 23 +-
.../Validators/Rules/HomepageSectionRules.php | 31 +
backend/composer.json | 2 +-
backend/config/app.php | 2 +-
backend/config/queue.php | 50 +-
backend/config/sso.php | 2 -
backend/ee/LICENCE | 35 +-
.../Domain/AccountSsoConnectionService.php | 6 +-
.../Domain/DTO/LinkableSsoConnection.php | 16 +
.../Services/Domain/EnterpriseSsoResolver.php | 37 +-
.../Domain/SsoConnectionLinkService.php | 89 +
.../Services/Domain/SsoEnforcementService.php | 76 +-
backend/lang/de.json | 23 +-
backend/lang/el.json | 23 +-
backend/lang/es.json | 23 +-
backend/lang/fi.json | 23 +-
backend/lang/fr.json | 23 +-
backend/lang/hu.json | 23 +-
backend/lang/it.json | 23 +-
backend/lang/ja.json | 23 +-
backend/lang/ko.json | 23 +-
backend/lang/nl.json | 23 +-
backend/lang/pl.json | 23 +-
backend/lang/pt-br.json | 23 +-
backend/lang/pt.json | 23 +-
backend/lang/ru.json | 25 +-
backend/lang/se.json | 23 +-
backend/lang/sk.json | 23 +-
backend/lang/tr.json | 23 +-
backend/lang/vi.json | 23 +-
backend/lang/zh-cn.json | 23 +-
backend/lang/zh-hk.json | 23 +-
backend/resources/api-reference/openapi.json | 504 ++-
.../account/admin-access-granted.blade.php | 9 +
.../emails/orders/attendee-ticket.blade.php | 6 +-
.../views/vendor/mail/html/panel.blade.php | 22 +-
.../views/vendor/mail/html/table.blade.php | 2 +-
backend/routes/api.php | 2 +-
.../Feature/Auth/ResetPasswordErrorsTest.php | 75 +
.../tests/Feature/Auth/ResetPasswordTest.php | 2 +-
.../Auth/Sso/EnterpriseSsoLoginTest.php | 64 +-
.../Auth/Sso/SsoConnectionLinkTest.php | 378 ++
.../Feature/Auth/Sso/SsoEnforcementTest.php | 170 +
.../Auth/Sso/SsoEnforcementTwoFactorTest.php | 129 +
.../Auth/Sso/SsoInvitationEnforcementTest.php | 147 +
.../Feature/Console/SsoStatusCommandTest.php | 55 +
.../Sso/AccountSsoConnectionSecretTest.php | 148 +
.../Sso/EnterpriseSsoLicenceLapseTest.php | 128 +
.../Accounts/Sso/AccountSsoSettingsTest.php | 5 +
.../Admin/Users/ImpersonationTokenTest.php | 157 +
.../LegacyCheckInAttendeeActionTest.php | 131 +
.../PartialEditAttendeeActionTest.php | 94 +
.../HomepageSectionValidationMessagesTest.php | 155 +
.../Lineup/LineupHtmlPurificationTest.php | 109 +
.../Actions/Lineup/LineupImportLockTest.php | 55 +
.../Lineup/LineupMalformedInputTest.php | 110 +
.../Lineup/PublicLineupScheduleDaysTest.php | 95 +
.../Messages/SendMessageActionTest.php | 62 +
.../MarkOrderAsPaidInvoiceEmailTest.php | 90 +
.../GetOrderPublicCheckInStatusTest.php | 72 +
...zerSettingsAttendeeSelfEditDefaultTest.php | 45 +
.../Questions/UpsertQuestionActionTest.php | 102 +
.../BuyerSeatChangeSelfServiceTest.php | 86 +
.../TaxesAndFees/TaxOrFeeActionsTest.php | 98 +
.../Actions/Users/UpdateUserActionTest.php | 167 +
.../Http/Actions/Widgets/WidgetCrudTest.php | 134 +-
.../Http/ApiKeys/ApiKeyAuthenticationTest.php | 14 +
.../Http/ApiKeys/ApiKeyManagementTest.php | 14 +
.../Http/BoxOfficeReadRateLimitTest.php | 98 +
.../TeamAccess/DuplicateEventAccessTest.php | 88 +
.../TeamAccess/FinancialActionGatesTest.php | 165 +
.../TeamAccess/FinancialPermissionsTest.php | 10 +-
.../TeamAccess/FinancialQueryMetaTest.php | 76 +
.../TeamAccess/NestedMoneyRedactionTest.php | 237 ++
.../OfflinePaymentInstructionsAccessTest.php | 225 ++
...PaymentInstructionsChangeDetectionTest.php | 145 +
.../PaymentSettingsPermissionTest.php | 135 +
.../TeamOverviewAndNotificationsTest.php | 46 +-
.../OpenApi/IntegrationApiReferenceTest.php | 105 +
.../AccountUserAccessGrantRepositoryTest.php | 66 +
.../PartialEditAttendeeHandlerTest.php | 407 ++
.../Payment/Stripe/StripeRefundFlowTest.php | 298 ++
.../Anonymization/OrganizerAnonymizerTest.php | 114 +
...piKeyExpiryReminderPendingDeletionTest.php | 78 +
...mOrderNotificationRecipientServiceTest.php | 79 +
.../Webhook/WebhookDeliveryRetryTest.php | 171 +
.../Webhook/WebhookDispatchServiceTest.php | 174 +
.../QueueDatabaseConnectionTest.php | 67 +
backend/tests/Unit/Helper/UrlCdnTest.php | 43 +
.../GenerateOccurrencesRequestTest.php | 44 +
.../Account/AdminAccessGrantedMailTest.php | 48 +
.../AttendeeTicketMailOccurrenceDatesTest.php | 168 +
.../Unit/Mail/User/ApiKeyCreatedMailTest.php | 37 +
.../Admin/ApproveMessageHandlerTest.php | 103 +
.../PartialEditAttendeeHandlerTest.php | 190 +-
.../Lineup/GetEventLineupHandlerTest.php | 31 +-
.../SendMessageHandlerScheduledTest.php | 100 +-
.../Payment/Stripe/RefundOrderHandlerTest.php | 155 +-
...dateOrganizerSettingsEventDefaultsTest.php | 112 +
.../UpdateMeHandlerPasswordChangeTest.php | 89 +
.../Email/EmailTokenContextBuilderTest.php | 28 +
.../EventSpamCheckContentServiceTest.php | 183 +-
.../Event/EventSpamCheckPromptBudgetTest.php | 246 ++
.../EmailLineupHighlightsServiceTest.php | 3 +-
.../Lineup/LineupHighlightSelectorTest.php | 90 +
.../SendEventEmailMessagesServiceTest.php | 185 +
.../Mail/SendOrderDetailsServiceTest.php | 37 +-
.../Message/MessageDispatchServiceTest.php | 51 +-
.../Order/MarkOrderAsPaidServiceTest.php | 34 +-
.../ChargeRefundUpdatedHandlerTest.php | 275 +-
.../StripePaymentIntentRefundServiceTest.php | 99 +-
.../StripeRefundExpiredOrderServiceTest.php | 62 +
...ProductFilterServiceDisplayPricingTest.php | 458 +++
.../Lock/TransactionLockServiceTest.php | 11 +
docker/all-in-one/.env.example | 7 +-
docker/all-in-one/README.md | 22 +
docker/all-in-one/docker-compose.yml | 3 +-
e2e/README.md | 4 +
e2e/api/api-client.ts | 22 +
e2e/pages/lineup.page.ts | 4 +-
e2e/pages/organizer.page.ts | 4 +-
e2e/pages/promo-code.page.ts | 8 +-
e2e/pages/sso.page.ts | 10 +-
.../account/api-key-create-modal.spec.ts | 34 +
.../account/api-reference-landmarks.spec.ts | 18 +
.../account/legacy-settings-redirects.spec.ts | 25 +
.../account/member-access-states.spec.ts | 119 +
.../account/pending-deletion-member.spec.ts | 31 +
e2e/tests/account/read-only-access.spec.ts | 3 +-
.../account/settings-dialog-focus.spec.ts | 131 +
.../account/settings-load-errors.spec.ts | 56 +
.../sign-in-methods-load-error.spec.ts | 26 +
e2e/tests/account/team-access.spec.ts | 6 +-
e2e/tests/account/team-edit-member.spec.ts | 34 +
e2e/tests/account/team-invite.spec.ts | 46 +
e2e/tests/auth/account-chooser.spec.ts | 30 +
.../auth/auth-error-redirect.unit.spec.ts | 83 +
.../enterprise-sso-access-settings.spec.ts | 73 +
...-sso-member-owning-another-account.spec.ts | 94 +
.../auth/enterprise-sso-setup-form.spec.ts | 94 +
e2e/tests/auth/enterprise-sso.spec.ts | 146 +-
e2e/tests/auth/password.spec.ts | 2 +-
.../auth/session-expiry-redirect.spec.ts | 16 +
e2e/tests/auth/team-member.shared.ts | 47 +
.../promo-code-inclusive-platform-fee.spec.ts | 47 +
.../checkout/promo-code-inclusive-tax.spec.ts | 40 +
.../checkout/promo-code-keyboard.spec.ts | 43 +
e2e/tests/licensing/licence-states.spec.ts | 18 +
.../lineup/lineup-schedule-days.unit.spec.ts | 84 +
e2e/tests/lineup/lineup-schedule.unit.spec.ts | 23 +
.../lineup/public-lineup-mixed-photos.spec.ts | 67 +
e2e/tests/lineup/public-lineup.spec.ts | 211 ++
e2e/tests/lineup/public-lineup.unit.spec.ts | 141 +
e2e/tests/management/app-shell.spec.ts | 75 +
.../date-time-field-default-time.unit.spec.ts | 26 +
e2e/tests/management/date-time-field.spec.ts | 198 +
.../designer-widget-controls.spec.ts | 119 +
.../management/dialog-accessibility.spec.ts | 190 +
.../homepage-designer-limits.spec.ts | 151 +
.../management/homepage-media-focus.spec.ts | 49 +
.../homepage-preview-messages.spec.ts | 180 +
.../homepage-sections-editor-guards.spec.ts | 102 +
.../management/lineup-import-export.spec.ts | 40 +-
e2e/tests/management/lineup-manage.spec.ts | 220 ++
e2e/tests/management/messages.spec.ts | 100 +-
...ffline-payment-instructions-access.spec.ts | 34 +
.../organizer-week-start-modals.spec.ts | 37 +
.../management/product-duplicate.spec.ts | 60 +
e2e/tests/management/promo-codes.spec.ts | 15 +
.../management/report-load-errors.spec.ts | 45 +
.../management/sortable-announcements.spec.ts | 69 +
.../organizer/organizer-management.spec.ts | 2 +-
.../seat-change-eligibility.unit.spec.ts | 61 +
.../seating/seat-change-visibility.spec.ts | 69 +
e2e/tests/seating/seat-lifecycle-ui.spec.ts | 9 +-
e2e/tests/ssr/html-lang.spec.ts | 23 +
e2e/tests/ssr/ssr-locale-hydration.spec.ts | 137 +
e2e/tests/utils/unique.unit.spec.ts | 13 +
.../widget/embed-seats-unavailable.spec.ts | 37 +
e2e/tests/widget/embed-seats.spec.ts | 33 +-
.../widget/embedded-lineup-dialogs.spec.ts | 99 +
...ice-before-discount-inclusive.unit.spec.ts | 83 +
e2e/tests/widget/price-totals.unit.spec.ts | 107 +
e2e/tests/widget/theme-contrast.unit.spec.ts | 33 +
.../theme-tertiary-contrast.unit.spec.ts | 18 +
e2e/tests/widget/widget-api.ts | 49 +
e2e/tests/widget/widget-config.unit.spec.ts | 16 +-
e2e/tests/widget/widget-editor.spec.ts | 80 +-
e2e/tests/widget/widget-host.spec.ts | 102 +
e2e/tests/widget/widget-modes.spec.ts | 129 +-
e2e/tests/widget/widget-playground.spec.ts | 10 +-
e2e/tests/widget/widget-robots.spec.ts | 22 +
e2e/utils/unique.ts | 6 +-
frontend/package.json | 2 +-
frontend/public/widget.js | 52 +-
frontend/server.js | 20 +-
frontend/src/api/authErrorRedirect.ts | 55 +
frontend/src/api/client.ts | 57 +-
frontend/src/api/lineup.client.ts | 1 +
frontend/src/api/public-client.ts | 2 +-
frontend/src/api/sso.client.ts | 14 +-
frontend/src/api/user.client.ts | 2 +-
.../common/CalendarOptionsPopover/index.tsx | 61 +-
.../ConfirmIdentity/ConfirmIdentityModal.tsx | 18 +-
.../common/ConfirmIdentity/index.tsx | 1 +
.../components/common/CopyButton/index.tsx | 31 +-
.../common/DateField/DateField.module.scss | 9 +
.../src/components/common/DateField/index.tsx | 39 +-
.../DateTimeField/DateTimeField.module.scss | 6 +-
.../common/DateTimeField/dateTimeValue.ts | 2 +
.../components/common/DateTimeField/index.tsx | 22 +-
.../components/common/GlobalMenu/index.tsx | 3 +-
.../common/HomepageDesigner/index.tsx | 83 +-
.../common/KpiGrid/KpiGrid.module.scss | 16 +
.../common/LanguageSwitcher/index.tsx | 57 +-
.../src/components/common/Modal/index.tsx | 4 +
.../common/OneTimeCodeInput/index.tsx | 1 +
.../common/OrganizerReportTable/index.tsx | 11 +
.../PeriodSelector/PeriodSelector.module.scss | 2 +
.../common/PlatformFeesSettings/index.tsx | 37 +-
.../common/QuestionsTable/index.tsx | 5 +-
.../components/common/ReportTable/index.tsx | 16 +-
.../components/common/SettingsPage/index.tsx | 72 +-
.../common/SideDrawer/SideDrawer.module.scss | 22 +-
.../components/common/SideDrawer/index.tsx | 10 +-
.../common/SsoButtons/SsoSection.tsx | 5 +-
.../TeamAccessFields.module.scss | 15 +
.../common/TeamAccessFields/index.tsx | 57 +-
.../common/TwoFactor/RecoveryCodesDisplay.tsx | 1 +
.../common/WidgetEditor/AppearancePanel.tsx | 11 +-
.../common/WidgetEditor/BehaviourPanel.tsx | 3 +-
.../common/WidgetEditor/ContentFields.tsx | 10 +-
.../common/WidgetEditor/SectionsPicker.tsx | 4 +-
.../common/WidgetEditor/editorSettings.ts | 25 +-
.../common/WidgetEditor/embedCode.ts | 7 +-
.../components/common/WidgetEditor/index.tsx | 16 +-
.../components/common/WriteAccess/index.tsx | 29 +
.../forms/CheckInListForm/index.tsx | 1 +
.../ProductForm/ProductPriceTierForm.tsx | 6 +-
.../components/forms/ProductForm/index.tsx | 25 +-
.../components/forms/PromoCodeForm/index.tsx | 1 +
.../layouts/AppLayout/AppLayout.module.scss | 22 +
.../AppLayout/Sidebar/Sidebar.module.scss | 2 +
.../layouts/AppLayout/Sidebar/index.tsx | 17 +-
.../layouts/AppLayout/Topbar/index.tsx | 4 +
.../components/layouts/AppLayout/index.tsx | 37 +-
.../Checkout/CheckoutThemeProvider.tsx | 2 +-
.../src/components/layouts/Event/index.tsx | 13 +-
.../layouts/EventContentWidget/index.tsx | 12 +-
.../EventHomepage/EventHomepage.module.scss | 1 +
.../EventHomepage/Lineup/Lineup.module.scss | 77 +-
.../EventHomepage/Lineup/ProfileModal.tsx | 1 +
.../EventHomepage/Lineup/ScheduleTimeline.tsx | 60 +-
.../layouts/EventHomepage/Lineup/index.tsx | 2 +
.../EventHomepage/Sections/FaqSection.tsx | 24 +-
.../EventHomepage/Sections/GallerySection.tsx | 24 +-
.../Sections/Sections.module.scss | 12 +-
.../EventHomepage/Sections/VideoSection.tsx | 10 +-
.../layouts/EventHomepage/index.tsx | 16 +-
.../layouts/EventHomepagePreview/index.tsx | 6 +-
.../layouts/OrganizerEventsWidget/index.tsx | 19 +-
.../OrganizerHomepage.module.scss | 2 +-
.../OrganizerHomepagePreview/index.tsx | 4 +-
.../OrganizerLayout.module.scss | 2 +-
.../layouts/OrganizerLayout/index.tsx | 14 +-
.../layouts/ProductWidget/index.tsx | 5 +-
.../layouts/SettingsLayout/index.tsx | 27 +-
.../modals/CreateAttendeeModal/index.tsx | 4 +-
.../SelectedOrganizerDates.tsx | 30 +
.../modals/CreateEventModal/index.tsx | 5 +-
.../CreateOrganizerWebhookModal/index.tsx | 2 +-
.../modals/CreateWebhookModal/index.tsx | 2 +-
.../modals/DuplicateEventModal/index.tsx | 26 +-
.../components/modals/EditUserModal/index.tsx | 21 +-
.../modals/ManageOccurrenceModal/index.tsx | 10 +-
.../modals/SendMessageModal/index.tsx | 35 +-
.../routes/auth/AcceptInvitation/index.tsx | 25 +-
.../routes/auth/Login/AccountChooser.tsx | 11 +-
.../routes/auth/Login/Login.module.scss | 4 +-
.../routes/auth/Login/LoginStepView.tsx | 5 +
.../components/routes/auth/Login/index.tsx | 16 +-
.../auth/RequiredTwoFactorSetup/index.tsx | 1 -
.../SectionsEditor/SectionContentEditor.tsx | 30 +-
.../SectionsEditor/SectionsEditor.module.scss | 3 +-
.../HomepageDesigner/SectionsEditor/index.tsx | 136 +-
.../SectionsEditor/sectionEmptyState.ts | 9 +-
.../routes/event/HomepageDesigner/index.tsx | 4 +-
.../routes/event/Lineup/AgendaList.tsx | 4 +-
.../Lineup/ImportLineupModal.module.scss | 15 +-
.../routes/event/Lineup/ImportLineupModal.tsx | 85 +-
.../routes/event/Lineup/Lineup.module.scss | 21 +-
.../routes/event/Lineup/LineupStats.tsx | 25 +-
.../routes/event/Lineup/PeopleRail.tsx | 22 +-
.../routes/event/Lineup/ProfileDrawer.tsx | 3 +-
.../event/Lineup/ProfileImagesField.tsx | 60 +-
.../routes/event/Lineup/ProfileLinksField.tsx | 2 +-
.../routes/event/Lineup/QuickAddPerson.tsx | 2 +-
.../ScheduleBuilder/ScheduleCalendar.tsx | 1 -
.../ScheduleBuilder/ScheduleItemModal.tsx | 16 +-
.../event/Lineup/ScheduleBuilder/index.tsx | 42 +-
.../Lineup/ScheduleBuilder/scheduleDays.ts | 65 +
.../components/routes/event/Lineup/index.tsx | 10 +-
.../routes/event/Lineup/useOccurrenceDays.ts | 14 +
.../GroupedOccurrenceTable/index.tsx | 64 +-
.../OccurrenceEditModal/index.tsx | 1 +
.../routes/event/OccurrencesTab/index.tsx | 1 +
.../event/Reports/ReportLayout/index.tsx | 9 +-
.../components/routes/event/Reports/index.tsx | 35 +-
.../Sections/EventDetailsForm/index.tsx | 1 +
.../Sections/PaymentSettings/index.tsx | 63 +-
.../routes/event/Webhooks/index.tsx | 25 +-
.../src/components/routes/event/messages.tsx | 24 +-
.../routes/organizer/Events/index.tsx | 4 +-
.../OrganizerDashboard.module.scss | 2 +-
.../OrganizerHomepageDesigner/index.tsx | 4 +-
.../routes/organizer/People/index.tsx | 22 +-
.../organizer/Reports/ReportLayout/index.tsx | 10 +-
.../routes/organizer/Webhooks/index.tsx | 69 +-
.../OrderSummaryAndProducts/index.tsx | 5 +-
.../SelectProducts/Prices/Tiered/index.tsx | 11 +-
.../product-widget/SelectProducts/index.tsx | 17 +-
.../SelectProducts/priceTotals.ts | 92 +
.../settings/ApiKeys/ApiKeys.module.scss | 19 +-
.../settings/ApiKeys/CreateApiKeyModal.tsx | 17 +-
.../routes/settings/ApiReference/index.tsx | 19 +-
.../Organizers/Organizers.module.scss | 4 +
.../routes/settings/Organizers/index.tsx | 17 +-
.../routes/settings/Profile/index.tsx | 12 +-
.../Security/RegenerateRecoveryCodesModal.tsx | 72 +-
.../settings/Security/SignInMethods.tsx | 54 +-
.../routes/settings/TaxesAndFees/index.tsx | 16 +-
.../routes/settings/Team/MemberRow.tsx | 19 +-
.../routes/settings/Team/Team.module.scss | 4 +
.../components/routes/settings/Team/index.tsx | 16 +-
.../sso/Callback/SsoCallback.module.scss | 4 +-
.../components/routes/sso/Callback/index.tsx | 4 +-
.../widget/EmbeddedDialogOffset.tsx | 131 +-
.../components/widget/WidgetFrame/index.tsx | 49 +-
.../widget/WidgetSections/HeavySections.tsx | 5 +
.../widget/embeddedDialogPlacement.ts | 36 +
.../src/components/widget/useWidgetRuntime.ts | 3 +-
frontend/src/ee/LICENCE | 35 +-
.../components/forms/BoxOfficeForm/index.tsx | 1 +
.../components/LicenceLockedCallout/index.tsx | 2 +-
.../components/LicenceModal/index.tsx | 45 +-
.../components/routes/admin/Licence/index.tsx | 2 +-
frontend/src/ee/licensing/licenceNotice.ts | 5 +-
.../ChangeSeatButton/canAttendeeChangeSeat.ts | 13 +
.../components/layouts/SeatCheckout/index.tsx | 28 +-
.../SingleSignOn/AccessSettings.tsx | 20 +-
.../SingleSignOn/ConnectionForm.tsx | 43 +-
.../components/SingleSignOn/DomainList.tsx | 8 +-
.../SingleSignOn/EnforcementPanel.tsx | 26 +-
.../SingleSignOn/SingleSignOn.module.scss | 2 +-
.../SingleSignOn/SsoSetupDrawer.tsx | 39 +-
.../sso/mutations/useAccountSsoMutations.ts | 55 +-
frontend/src/embed/widget.js | 52 +-
frontend/src/entry.server.tsx | 11 +-
frontend/src/hooks/useLoginSteps.ts | 14 +-
frontend/src/hooks/useReturnFocusOnClose.ts | 42 +
.../src/hooks/useSortableAccessibility.ts | 46 +
frontend/src/locales.ts | 92 +-
frontend/src/locales/de.js | 2 +-
frontend/src/locales/de.po | 3296 ++++++++--------
frontend/src/locales/el.js | 2 +-
frontend/src/locales/el.po | 3296 ++++++++--------
frontend/src/locales/en.js | 2 +-
frontend/src/locales/en.po | 3296 ++++++++--------
frontend/src/locales/es.js | 2 +-
frontend/src/locales/es.po | 3298 ++++++++--------
frontend/src/locales/fi.js | 2 +-
frontend/src/locales/fi.po | 3296 ++++++++--------
frontend/src/locales/fr.js | 2 +-
frontend/src/locales/fr.po | 3298 ++++++++--------
frontend/src/locales/hu.js | 2 +-
frontend/src/locales/hu.po | 3296 ++++++++--------
frontend/src/locales/it.js | 2 +-
frontend/src/locales/it.po | 3296 ++++++++--------
frontend/src/locales/ja.js | 2 +-
frontend/src/locales/ja.po | 3296 ++++++++--------
frontend/src/locales/ko.js | 2 +-
frontend/src/locales/ko.po | 3296 ++++++++--------
frontend/src/locales/nl.js | 2 +-
frontend/src/locales/nl.po | 3298 ++++++++--------
frontend/src/locales/pl.js | 2 +-
frontend/src/locales/pl.po | 3296 ++++++++--------
frontend/src/locales/pt-br.js | 2 +-
frontend/src/locales/pt-br.po | 3300 ++++++++--------
frontend/src/locales/pt.js | 2 +-
frontend/src/locales/pt.po | 3298 ++++++++--------
frontend/src/locales/ru.js | 2 +-
frontend/src/locales/ru.po | 3296 ++++++++--------
frontend/src/locales/se.js | 2 +-
frontend/src/locales/se.po | 3296 ++++++++--------
frontend/src/locales/sk.js | 2 +-
frontend/src/locales/sk.po | 3300 ++++++++--------
frontend/src/locales/tr.js | 2 +-
frontend/src/locales/tr.po | 3296 ++++++++--------
frontend/src/locales/vi.js | 2 +-
frontend/src/locales/vi.po | 3310 +++++++++--------
frontend/src/locales/zh-cn.js | 2 +-
frontend/src/locales/zh-cn.po | 3300 ++++++++--------
frontend/src/locales/zh-hk.js | 2 +-
frontend/src/locales/zh-hk.po | 3300 ++++++++--------
.../src/mutations/useDeleteLineupEntry.ts | 2 +-
.../src/mutations/useStartImpersonation.ts | 1 -
.../src/mutations/useStopImpersonation.ts | 1 -
.../src/mutations/useUpsertLineupEntry.ts | 2 +-
.../queries/useGetOrganizerWidgetEvents.ts | 7 +-
frontend/src/queries/useGetSsoIdentities.ts | 16 +-
.../publicOrganizerRouteLoader.ts | 4 +-
.../src/routeLoaders/widgetRouteLoader.ts | 13 +-
frontend/src/router.tsx | 22 +-
frontend/src/types.ts | 12 +-
frontend/src/utilites/confirmationDialog.tsx | 2 +
frontend/src/utilites/currency.ts | 5 +-
frontend/src/utilites/lineupSchedule.ts | 59 +-
frontend/src/utilites/notifications.tsx | 7 +
frontend/src/utilites/ssrRequestContext.ts | 1 +
frontend/src/utilites/themeUtils.ts | 15 +-
frontend/src/utilites/widgetConfig.ts | 12 +-
frontend/src/utilites/widgetTheme.ts | 2 +-
scripts/set-version.sh | 3 +
513 files changed, 56366 insertions(+), 33360 deletions(-)
create mode 100644 backend/app/Services/Application/Handlers/Auth/DTO/InvitationDetailsDTO.php
create mode 100644 backend/app/Services/Application/Handlers/Auth/GetInvitationHandler.php
create mode 100644 backend/app/Services/Application/Handlers/Auth/Sso/DTO/SsoSignInMethodsResult.php
create mode 100644 backend/app/Services/Domain/Lineup/DTO/LineupHighlightSelectionDTO.php
create mode 100644 backend/app/Services/Domain/Lineup/LineupHighlightSelector.php
create mode 100644 backend/ee/Sso/Services/Domain/DTO/LinkableSsoConnection.php
create mode 100644 backend/ee/Sso/Services/Domain/SsoConnectionLinkService.php
create mode 100644 backend/tests/Feature/Auth/ResetPasswordErrorsTest.php
create mode 100644 backend/tests/Feature/Auth/Sso/SsoConnectionLinkTest.php
create mode 100644 backend/tests/Feature/Auth/Sso/SsoEnforcementTwoFactorTest.php
create mode 100644 backend/tests/Feature/Auth/Sso/SsoInvitationEnforcementTest.php
create mode 100644 backend/tests/Feature/Console/SsoStatusCommandTest.php
create mode 100644 backend/tests/Feature/Enterprise/Sso/AccountSsoConnectionSecretTest.php
create mode 100644 backend/tests/Feature/Enterprise/Sso/EnterpriseSsoLicenceLapseTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Admin/Users/ImpersonationTokenTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Attendees/LegacyCheckInAttendeeActionTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Attendees/PartialEditAttendeeActionTest.php
create mode 100644 backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionValidationMessagesTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Lineup/LineupHtmlPurificationTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Lineup/LineupImportLockTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Lineup/LineupMalformedInputTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Lineup/PublicLineupScheduleDaysTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Messages/SendMessageActionTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Orders/MarkOrderAsPaidInvoiceEmailTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Orders/Public/GetOrderPublicCheckInStatusTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Organizers/OrganizerSettingsAttendeeSelfEditDefaultTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Questions/UpsertQuestionActionTest.php
create mode 100644 backend/tests/Feature/Http/Actions/SeatMaps/BuyerSeatChangeSelfServiceTest.php
create mode 100644 backend/tests/Feature/Http/Actions/TaxesAndFees/TaxOrFeeActionsTest.php
create mode 100644 backend/tests/Feature/Http/Actions/Users/UpdateUserActionTest.php
create mode 100644 backend/tests/Feature/Http/BoxOfficeReadRateLimitTest.php
create mode 100644 backend/tests/Feature/Http/TeamAccess/DuplicateEventAccessTest.php
create mode 100644 backend/tests/Feature/Http/TeamAccess/FinancialActionGatesTest.php
create mode 100644 backend/tests/Feature/Http/TeamAccess/FinancialQueryMetaTest.php
create mode 100644 backend/tests/Feature/Http/TeamAccess/NestedMoneyRedactionTest.php
create mode 100644 backend/tests/Feature/Http/TeamAccess/OfflinePaymentInstructionsAccessTest.php
create mode 100644 backend/tests/Feature/Http/TeamAccess/OfflinePaymentInstructionsChangeDetectionTest.php
create mode 100644 backend/tests/Feature/Http/TeamAccess/PaymentSettingsPermissionTest.php
create mode 100644 backend/tests/Feature/Repository/Eloquent/AccountUserAccessGrantRepositoryTest.php
create mode 100644 backend/tests/Feature/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php
create mode 100644 backend/tests/Feature/Services/Application/Handlers/Order/Payment/Stripe/StripeRefundFlowTest.php
create mode 100644 backend/tests/Feature/Services/Domain/Account/Anonymization/OrganizerAnonymizerTest.php
create mode 100644 backend/tests/Feature/Services/Domain/Auth/ApiKey/ApiKeyExpiryReminderPendingDeletionTest.php
create mode 100644 backend/tests/Feature/Services/Domain/Mail/TeamOrderNotificationRecipientServiceTest.php
create mode 100644 backend/tests/Feature/Services/Infrastructure/Webhook/WebhookDeliveryRetryTest.php
create mode 100644 backend/tests/Feature/Services/Infrastructure/Webhook/WebhookDispatchServiceTest.php
create mode 100644 backend/tests/Unit/Configuration/QueueDatabaseConnectionTest.php
create mode 100644 backend/tests/Unit/Helper/UrlCdnTest.php
create mode 100644 backend/tests/Unit/Http/Request/EventOccurrence/GenerateOccurrencesRequestTest.php
create mode 100644 backend/tests/Unit/Mail/Account/AdminAccessGrantedMailTest.php
create mode 100644 backend/tests/Unit/Mail/Attendee/AttendeeTicketMailOccurrenceDatesTest.php
create mode 100644 backend/tests/Unit/Mail/User/ApiKeyCreatedMailTest.php
create mode 100644 backend/tests/Unit/Services/Application/Handlers/Admin/ApproveMessageHandlerTest.php
create mode 100644 backend/tests/Unit/Services/Application/Handlers/OrganizerSettings/PartialUpdateOrganizerSettingsEventDefaultsTest.php
create mode 100644 backend/tests/Unit/Services/Application/Handlers/User/UpdateMeHandlerPasswordChangeTest.php
create mode 100644 backend/tests/Unit/Services/Domain/Event/EventSpamCheckPromptBudgetTest.php
create mode 100644 backend/tests/Unit/Services/Domain/Lineup/LineupHighlightSelectorTest.php
create mode 100644 backend/tests/Unit/Services/Domain/Product/ProductFilterServiceDisplayPricingTest.php
create mode 100644 e2e/tests/account/api-key-create-modal.spec.ts
create mode 100644 e2e/tests/account/api-reference-landmarks.spec.ts
create mode 100644 e2e/tests/account/legacy-settings-redirects.spec.ts
create mode 100644 e2e/tests/account/member-access-states.spec.ts
create mode 100644 e2e/tests/account/pending-deletion-member.spec.ts
create mode 100644 e2e/tests/account/settings-dialog-focus.spec.ts
create mode 100644 e2e/tests/account/settings-load-errors.spec.ts
create mode 100644 e2e/tests/account/sign-in-methods-load-error.spec.ts
create mode 100644 e2e/tests/account/team-edit-member.spec.ts
create mode 100644 e2e/tests/auth/account-chooser.spec.ts
create mode 100644 e2e/tests/auth/auth-error-redirect.unit.spec.ts
create mode 100644 e2e/tests/auth/enterprise-sso-access-settings.spec.ts
create mode 100644 e2e/tests/auth/enterprise-sso-member-owning-another-account.spec.ts
create mode 100644 e2e/tests/auth/enterprise-sso-setup-form.spec.ts
create mode 100644 e2e/tests/auth/session-expiry-redirect.spec.ts
create mode 100644 e2e/tests/auth/team-member.shared.ts
create mode 100644 e2e/tests/checkout/promo-code-inclusive-platform-fee.spec.ts
create mode 100644 e2e/tests/checkout/promo-code-inclusive-tax.spec.ts
create mode 100644 e2e/tests/checkout/promo-code-keyboard.spec.ts
create mode 100644 e2e/tests/lineup/lineup-schedule-days.unit.spec.ts
create mode 100644 e2e/tests/lineup/public-lineup-mixed-photos.spec.ts
create mode 100644 e2e/tests/lineup/public-lineup.spec.ts
create mode 100644 e2e/tests/lineup/public-lineup.unit.spec.ts
create mode 100644 e2e/tests/management/app-shell.spec.ts
create mode 100644 e2e/tests/management/date-time-field-default-time.unit.spec.ts
create mode 100644 e2e/tests/management/date-time-field.spec.ts
create mode 100644 e2e/tests/management/designer-widget-controls.spec.ts
create mode 100644 e2e/tests/management/dialog-accessibility.spec.ts
create mode 100644 e2e/tests/management/homepage-designer-limits.spec.ts
create mode 100644 e2e/tests/management/homepage-media-focus.spec.ts
create mode 100644 e2e/tests/management/homepage-preview-messages.spec.ts
create mode 100644 e2e/tests/management/homepage-sections-editor-guards.spec.ts
create mode 100644 e2e/tests/management/lineup-manage.spec.ts
create mode 100644 e2e/tests/management/offline-payment-instructions-access.spec.ts
create mode 100644 e2e/tests/management/organizer-week-start-modals.spec.ts
create mode 100644 e2e/tests/management/product-duplicate.spec.ts
create mode 100644 e2e/tests/management/report-load-errors.spec.ts
create mode 100644 e2e/tests/management/sortable-announcements.spec.ts
create mode 100644 e2e/tests/seating/seat-change-eligibility.unit.spec.ts
create mode 100644 e2e/tests/seating/seat-change-visibility.spec.ts
create mode 100644 e2e/tests/ssr/html-lang.spec.ts
create mode 100644 e2e/tests/ssr/ssr-locale-hydration.spec.ts
create mode 100644 e2e/tests/utils/unique.unit.spec.ts
create mode 100644 e2e/tests/widget/embed-seats-unavailable.spec.ts
create mode 100644 e2e/tests/widget/embedded-lineup-dialogs.spec.ts
create mode 100644 e2e/tests/widget/price-before-discount-inclusive.unit.spec.ts
create mode 100644 e2e/tests/widget/price-totals.unit.spec.ts
create mode 100644 e2e/tests/widget/theme-contrast.unit.spec.ts
create mode 100644 e2e/tests/widget/theme-tertiary-contrast.unit.spec.ts
create mode 100644 e2e/tests/widget/widget-api.ts
create mode 100644 e2e/tests/widget/widget-robots.spec.ts
create mode 100644 frontend/src/api/authErrorRedirect.ts
create mode 100644 frontend/src/components/common/DateField/DateField.module.scss
create mode 100644 frontend/src/components/common/DateTimeField/dateTimeValue.ts
create mode 100644 frontend/src/components/modals/CreateEventModal/SelectedOrganizerDates.tsx
create mode 100644 frontend/src/components/routes/event/Lineup/ScheduleBuilder/scheduleDays.ts
create mode 100644 frontend/src/components/routes/event/Lineup/useOccurrenceDays.ts
create mode 100644 frontend/src/components/routes/product-widget/SelectProducts/priceTotals.ts
create mode 100644 frontend/src/components/widget/embeddedDialogPlacement.ts
create mode 100644 frontend/src/ee/seating/components/ChangeSeatButton/canAttendeeChangeSeat.ts
create mode 100644 frontend/src/hooks/useReturnFocusOnClose.ts
create mode 100644 frontend/src/hooks/useSortableAccessibility.ts
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 (
-