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 - */ - public function published(): array + public function published(): stdClass { - $document = json_decode((string) file_get_contents($this->path()), true, flags: JSON_THROW_ON_ERROR); - $document['info']['version'] = trim((string) file_get_contents($this->app->basePath('VERSION'))); + $document = json_decode((string) file_get_contents($this->path()), flags: JSON_THROW_ON_ERROR); + $document->info->version = trim((string) file_get_contents($this->app->basePath('VERSION'))); return $document; } @@ -74,14 +76,11 @@ private static function isExcludedPath(string $path): bool return false; } - /** - * @param array $document - * @return array - */ - private function withReachableSchemasOnly(array $document): array + private function keepReachableSchemasOnly(stdClass $document): void { - $schemas = $document['components']['schemas'] ?? []; - $pending = self::schemaRefs([$document['paths'], array_diff_key($document['components'] ?? [], ['schemas' => true])]); + $components = (array) ($document->components ?? []); + $schemas = (array) ($components['schemas'] ?? []); + $pending = self::schemaRefs([$document->paths, array_diff_key($components, ['schemas' => true])]); $reachable = []; while ($pending !== []) { @@ -95,9 +94,8 @@ private function withReachableSchemasOnly(array $document): array array_push($pending, ...self::schemaRefs($schemas[$name])); } - $document['components']['schemas'] = array_intersect_key($schemas, $reachable); - - return $document; + $document->components ??= new stdClass; + $document->components->schemas = (object) array_intersect_key($schemas, $reachable); } /** @@ -110,19 +108,33 @@ private static function schemaRefs(mixed $node): array return $matches[1]; } - /** - * @param array $pathItem - * @return array - */ - private function withoutSessionOnlyOperations(array $pathItem): array + private static function closeTuplesWithItems(mixed $node): void { + if (! is_array($node) && ! $node instanceof stdClass) { + return; + } + + foreach ($node as $child) { + self::closeTuplesWithItems($child); + } + + if ($node instanceof stdClass && property_exists($node, 'additionalItems') && in_array('array', (array) ($node->type ?? null), true)) { + $node->items = $node->additionalItems; + unset($node->additionalItems); + } + } + + private function withoutSessionOnlyOperations(stdClass $pathItem): ?stdClass + { + $pathItem = (array) $pathItem; + $operations = array_filter( $pathItem, static fn ($operation, string $key) => in_array($key, self::HTTP_METHODS, true) - && ! ($operation['x-'.self::SESSION_ONLY_EXTENSION] ?? false), + && ! ($operation->{'x-'.self::SESSION_ONLY_EXTENSION} ?? false), ARRAY_FILTER_USE_BOTH, ); - return $operations === [] ? [] : $operations + array_diff_key($pathItem, array_flip(self::HTTP_METHODS)); + return $operations === [] ? null : (object) ($operations + array_diff_key($pathItem, array_flip(self::HTTP_METHODS))); } } diff --git a/backend/app/Providers/RouteServiceProvider.php b/backend/app/Providers/RouteServiceProvider.php index 1f96e31002..470e3341dd 100644 --- a/backend/app/Providers/RouteServiceProvider.php +++ b/backend/app/Providers/RouteServiceProvider.php @@ -14,18 +14,8 @@ class RouteServiceProvider extends ServiceProvider { - /** - * The path to your application's "home" route. - * - * Typically, users are redirected here after authentication. - * - * @var string - */ public const HOME = '/home'; - /** - * Define your route model bindings, pattern filters, and other route configuration. - */ public function boot(): void { RateLimiter::for('api', function (Request $request) { @@ -134,6 +124,16 @@ public function boot(): void ]; }); + RateLimiter::for('box-office-read', function (Request $request) { + return [ + Limit::perMinute(60)->by( + $this->boxOfficeSessionKey($request) + ?? 'box-office-read|'.$request->route('box_office_short_id').'|'.$request->ip() + ), + Limit::perMinute(600)->by('box-office-read-ip|'.$request->ip()), + ]; + }); + RateLimiter::for('public-order-create', function (Request $request) { return Limit::perMinute(config('app.public_order_rate_limit_per_minute')) ->by('public-order-create|'.$request->ip()); diff --git a/backend/app/Providers/ScrambleServiceProvider.php b/backend/app/Providers/ScrambleServiceProvider.php index 76f5286882..a15c7f2503 100644 --- a/backend/app/Providers/ScrambleServiceProvider.php +++ b/backend/app/Providers/ScrambleServiceProvider.php @@ -6,12 +6,15 @@ use Dedoc\Scramble\Scramble; use Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy; +use Dedoc\Scramble\Support\Generator\OpenApi; use Dedoc\Scramble\Support\Generator\Operation; +use Dedoc\Scramble\Support\Generator\Path; use Dedoc\Scramble\Support\Generator\SecurityScheme; use Dedoc\Scramble\Support\RouteInfo; use HiEvents\OpenApi\IntegrationApiReference; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Routing\Route; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; @@ -77,9 +80,23 @@ public function boot(): void $operation->description(trim($operation->description."\n\n".'Not available to API keys: sign in to call this endpoint.')); $operation->setExtensionProperty(IntegrationApiReference::SESSION_ONLY_EXTENSION, true); } + }) + ->withDocumentTransformers(static function (OpenApi $openApi): void { + collect($openApi->paths) + ->flatMap(static fn (Path $path): array => array_values($path->operations)) + ->filter(static fn (Operation $operation): bool => $operation->operationId !== null) + ->groupBy(static fn (Operation $operation): string => $operation->operationId) + ->filter(static fn (Collection $operations): bool => $operations->count() > 1) + ->flatten() + ->each(static fn (Operation $operation) => $operation->setOperationId(self::routeQualifiedOperationId($operation))); }); } + private static function routeQualifiedOperationId(Operation $operation): string + { + return $operation->getAttribute('operationId')->getFallbackEloquent().'.'.Str::camel($operation->method.' '.preg_replace('/[^A-Za-z0-9]+/', ' ', $operation->path)); + } + private static function summaryFromActionName(string $actionName): string { return Str::of($actionName)->headline()->explode(' ') diff --git a/backend/app/Repository/Eloquent/AccountUserAccessGrantRepository.php b/backend/app/Repository/Eloquent/AccountUserAccessGrantRepository.php index 7c1e32d8db..ac5624ac8c 100644 --- a/backend/app/Repository/Eloquent/AccountUserAccessGrantRepository.php +++ b/backend/app/Repository/Eloquent/AccountUserAccessGrantRepository.php @@ -31,13 +31,22 @@ public function findLiveGrantsForAccountUsers(array $accountUserIds): Collection return $this->runQuery(fn () => $this->handleResults( $this->model ->whereIn('account_user_id', $accountUserIds) - ->where(fn (Builder $query) => $query - ->whereNull('event_id') - ->orWhereIn('event_id', fn (QueryBuilder $events) => $events->select('id')->from('events')->whereNull('deleted_at'))) - ->where(fn (Builder $query) => $query - ->whereNull('organizer_id') - ->orWhereIn('organizer_id', fn (QueryBuilder $organizers) => $organizers->select('id')->from('organizers')->whereNull('deleted_at'))) + ->where(fn (Builder $query) => $this->targetIsLive($query, 'event_id', 'events')) + ->where(fn (Builder $query) => $this->targetIsLive($query, 'organizer_id', 'organizers')) ->get() )); } + + private function targetIsLive(Builder $query, string $column, string $targetTable): Builder + { + $grantColumn = $query->qualifyColumn($column); + + return $query + ->whereNull($grantColumn) + ->orWhereExists(fn (QueryBuilder $target) => $target + ->selectRaw('1') + ->from($targetTable) + ->whereColumn("$targetTable.id", $grantColumn) + ->whereNull("$targetTable.deleted_at")); + } } diff --git a/backend/app/Repository/Eloquent/PersonalAccessTokenRepository.php b/backend/app/Repository/Eloquent/PersonalAccessTokenRepository.php index 7045ceeacc..6958488cc4 100644 --- a/backend/app/Repository/Eloquent/PersonalAccessTokenRepository.php +++ b/backend/app/Repository/Eloquent/PersonalAccessTokenRepository.php @@ -4,10 +4,12 @@ use Carbon\CarbonImmutable; use HiEvents\DomainObjects\PersonalAccessTokenDomainObject; +use HiEvents\DomainObjects\Status\AccountDeletionRequestStatus; use HiEvents\DomainObjects\UserDomainObject; use HiEvents\Models\PersonalAccessToken; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\PersonalAccessTokenRepositoryInterface; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Collection; /** @@ -35,6 +37,11 @@ public function findDueForExpiryReminder(CarbonImmutable $now, int $windowDays, ->where(PersonalAccessTokenDomainObject::EXPIRES_AT, '>', $now) ->where(PersonalAccessTokenDomainObject::EXPIRES_AT, '<=', $now->addDays($windowDays)) ->whereRaw('created_at <= expires_at - make_interval(days => ?)', [$minimumLifetimeDays]) + ->whereNotExists(static fn (Builder $query) => $query + ->selectRaw('1') + ->from('account_deletion_requests') + ->whereColumn('account_deletion_requests.account_id', 'personal_access_tokens.account_id') + ->where('account_deletion_requests.status', AccountDeletionRequestStatus::REQUESTED->name)) ->orderBy(PersonalAccessTokenDomainObject::ID) ->get()); }); diff --git a/backend/app/Resources/Auth/InvitationResource.php b/backend/app/Resources/Auth/InvitationResource.php index 4687720e37..9c23a01090 100644 --- a/backend/app/Resources/Auth/InvitationResource.php +++ b/backend/app/Resources/Auth/InvitationResource.php @@ -3,23 +3,24 @@ namespace HiEvents\Resources\Auth; use HiEvents\Resources\BaseResource; -use HiEvents\Services\Domain\Auth\Invitation\PendingInvitation; +use HiEvents\Services\Application\Handlers\Auth\DTO\InvitationDetailsDTO; use Illuminate\Http\Request; /** - * @mixin PendingInvitation + * @mixin InvitationDetailsDTO */ class InvitationResource extends BaseResource { public function toArray(Request $request): array { return [ - 'first_name' => $this->user->getFirstName(), - 'last_name' => $this->user->getLastName(), - 'email' => $this->user->getEmail(), - 'timezone' => $this->user->getTimezone(), - 'account_name' => $this->accountName, - 'requires_sign_in' => $this->requiresSignIn, + 'first_name' => $this->invitation->user->getFirstName(), + 'last_name' => $this->invitation->user->getLastName(), + 'email' => $this->invitation->user->getEmail(), + 'timezone' => $this->invitation->user->getTimezone(), + 'account_name' => $this->invitation->accountName, + 'requires_sign_in' => $this->invitation->requiresSignIn, + 'sso_required' => $this->ssoRequired, ]; } } diff --git a/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php index 74eca71904..cf816f4672 100644 --- a/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php +++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php @@ -51,6 +51,7 @@ public function toArray(Request $request): array 'applies_to' => $allocation->applies_to, ], $this->getBookingLimits()->allocations), ]), + /** @var array{orders_created: int, attendees_registered: int, products_sold: int, total_gross_sales?: float, total_tax?: float, total_fee?: float, total_refunded?: float} */ 'statistics' => $this->when($stats !== null, fn () => [ 'orders_created' => $stats->getOrdersCreated() ?? 0, 'attendees_registered' => $stats->getAttendeesRegistered() ?? 0, diff --git a/backend/app/Resources/Lineup/EventLineupResourcePublic.php b/backend/app/Resources/Lineup/EventLineupResourcePublic.php index a79a24bd60..91bd21d754 100644 --- a/backend/app/Resources/Lineup/EventLineupResourcePublic.php +++ b/backend/app/Resources/Lineup/EventLineupResourcePublic.php @@ -16,7 +16,9 @@ class EventLineupResourcePublic extends BaseResource public function toArray(Request $request): array { return [ - 'entries' => LineupEntryResourcePublic::collection($this->entries), + 'entries' => LineupEntryResourcePublic::collectionWithAdditionalData($this->entries, [ + LineupEntryResourcePublic::SCHEDULE_DAYS_BY_ENTRY_ID => $this->schedule_days_by_entry_id, + ]), 'spaces' => EventSpaceResourcePublic::collection($this->spaces), 'schedule_items' => ScheduleItemResource::collection($this->schedule_items), ]; diff --git a/backend/app/Resources/Lineup/LineupEntryResourcePublic.php b/backend/app/Resources/Lineup/LineupEntryResourcePublic.php index de2e479b2f..aab6cd57bc 100644 --- a/backend/app/Resources/Lineup/LineupEntryResourcePublic.php +++ b/backend/app/Resources/Lineup/LineupEntryResourcePublic.php @@ -13,14 +13,22 @@ */ class LineupEntryResourcePublic extends BaseResource { + public const SCHEDULE_DAYS_BY_ENTRY_ID = 'schedule_days_by_entry_id'; + public function toArray(Request $request): array { + $scheduleDays = $this->getAdditionalDataByKey(self::SCHEDULE_DAYS_BY_ENTRY_ID); + return [ 'id' => $this->getId(), /** @var 'HEADLINER'|'FEATURED'|'STANDARD' */ 'billing_tier' => $this->getBillingTier(), 'role_label' => $this->getRoleLabel(), 'profile' => new ProfileResourcePublic($this->getProfile()), + $this->mergeWhen($scheduleDays !== null, fn () => [ + /** @var list */ + 'schedule_days' => $scheduleDays[$this->getId()] ?? [], + ]), ]; } } diff --git a/backend/app/Resources/Product/ProductPriceResourcePublic.php b/backend/app/Resources/Product/ProductPriceResourcePublic.php index 56d33e4fb0..0dd9db0ed6 100644 --- a/backend/app/Resources/Product/ProductPriceResourcePublic.php +++ b/backend/app/Resources/Product/ProductPriceResourcePublic.php @@ -23,6 +23,8 @@ public function toArray(Request $request): array 'sale_end_date' => $this->getSaleEndDate(), 'price_including_taxes_and_fees' => $this->getPriceIncludingTaxAndServiceFee(), 'price_before_discount' => $this->getPriceBeforeDiscount(), + /** @var float|null */ + 'price_before_discount_including_taxes_and_fees' => $this->getPriceBeforeDiscountIncludingTaxesAndFees(), 'is_discounted' => (bool) $this->getPriceBeforeDiscount(), 'tax_total' => $this->getTaxTotal(), 'fee_total' => $this->getFeeTotal(), diff --git a/backend/app/Resources/User/MeResource.php b/backend/app/Resources/User/MeResource.php index e503fcbacb..d3b2834c36 100644 --- a/backend/app/Resources/User/MeResource.php +++ b/backend/app/Resources/User/MeResource.php @@ -47,7 +47,7 @@ public function toArray(Request $request): array 'expires_at' => $licence->licence?->expires_at->toDateString(), 'grace_ends_at' => $licence->grace_ends_at?->toDateString(), 'invalid_reason' => $licence->invalid_reason, - /** @var array */ + /** @var array */ 'features_in_use' => array_map( static fn (LicensedFeature $feature) => $feature->value, $featuresInUse, diff --git a/backend/app/Resources/Widget/WidgetResource.php b/backend/app/Resources/Widget/WidgetResource.php index e58418f854..981161e1e6 100644 --- a/backend/app/Resources/Widget/WidgetResource.php +++ b/backend/app/Resources/Widget/WidgetResource.php @@ -26,6 +26,7 @@ public function toArray(Request $request): array 'type' => $this->getType(), 'event_id' => $this->getEventId(), 'organizer_id' => $this->getOrganizerId(), + /** @var array */ 'settings' => (object) $this->getSettings(), 'created_at' => $this->getCreatedAt(), 'updated_at' => $this->getUpdatedAt(), @@ -33,6 +34,7 @@ public function toArray(Request $request): array 'views' => $stats->views, 'orders' => $stats->orders, $this->mergeWhen($this->isPermitted(Permission::VIEW_FINANCIALS), fn () => [ + /** @var array */ 'revenue' => (object) $stats->revenue, ]), ], diff --git a/backend/app/Services/Application/Handlers/ApiKey/DeleteApiKeyHandler.php b/backend/app/Services/Application/Handlers/ApiKey/DeleteApiKeyHandler.php index bf3263462d..d799ed7c1e 100644 --- a/backend/app/Services/Application/Handlers/ApiKey/DeleteApiKeyHandler.php +++ b/backend/app/Services/Application/Handlers/ApiKey/DeleteApiKeyHandler.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\ApiKey; +use Carbon\CarbonImmutable; use HiEvents\DomainObjects\PersonalAccessTokenDomainObject; use HiEvents\DomainObjects\UserDomainObject; use HiEvents\Exceptions\ResourceNotFoundException; @@ -43,7 +44,7 @@ public function handle(DeleteApiKeyDTO $dto): void $owner = $apiKey->getUser(); - if ($isOwnKey || $owner === null) { + if ($isOwnKey || $owner === null || $this->hadExpired($apiKey)) { return; } @@ -55,4 +56,9 @@ public function handle(DeleteApiKeyDTO $dto): void accountName: $this->accountRepository->findById($dto->accountId)->getName(), )); } + + private function hadExpired(PersonalAccessTokenDomainObject $apiKey): bool + { + return $apiKey->getExpiresAt() !== null && CarbonImmutable::parse($apiKey->getExpiresAt())->isPast(); + } } diff --git a/backend/app/Services/Application/Handlers/Attendee/CheckInAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/CheckInAttendeeHandler.php index 9a74b7bd13..bcec859adb 100644 --- a/backend/app/Services/Application/Handlers/Attendee/CheckInAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/CheckInAttendeeHandler.php @@ -81,6 +81,10 @@ private function validateAction(AttendeeDomainObject $attendee, CheckInAttendeeD $isInvalidCheckIn = $attendee->getCheckedInAt() !== null && $checkInAttendeeDTO->action === CheckInAction::CHECK_IN; $isInvalidCheckOut = $attendee->getCheckedInAt() === null && $checkInAttendeeDTO->action === CheckInAction::CHECK_OUT; + if ($isInvalidCheckOut && $attendee->getCheckedOutBy() === null) { + throw new CannotCheckInException(__('This attendee is not checked in')); + } + if ($isInvalidCheckIn || $isInvalidCheckOut) { $user = $this->userRepository->findById( $checkInAttendeeDTO->action === CheckInAction::CHECK_IN ? $attendee->getCheckedInBy() : $attendee->getCheckedOutBy() diff --git a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php index 321f4c5da5..caa61e33d2 100644 --- a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php @@ -4,18 +4,22 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Enums\CapacityChangeDirection; +use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\Enterprise\Seating\Exceptions\SeatSelectionInvalidException; use HiEvents\Enterprise\Seating\Exceptions\SeatsUnavailableException; use HiEvents\Enterprise\Seating\Services\Domain\SeatClaimLiveness; 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\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\ProductQuantityUpdateService; use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; @@ -39,6 +43,9 @@ public function __construct( private readonly LoggerInterface $logger, private readonly SeatClaimService $seatClaimService, private readonly SeatedProductLookupService $seatedProductLookupService, + private readonly SeatingEventLockService $seatingEventLock, + private readonly OccurrencePurchaseEligibilityService $occurrenceEligibilityService, + private readonly AvailableProductQuantitiesFetchService $availableProductQuantitiesFetchService, ) {} /** @@ -47,11 +54,17 @@ public function __construct( public function handle(PartialEditAttendeeDTO $data): AttendeeDomainObject { return $this->databaseManager->transaction(function () use ($data) { - return $this->updateAttendee($data); + $status = $data->status ? strtoupper($data->status) : null; + + if ($status !== null) { + $this->seatingEventLock->lock($data->event_id); + } + + return $this->updateAttendee($data, $status); }); } - private function updateAttendee(PartialEditAttendeeDTO $data): AttendeeDomainObject + private function updateAttendee(PartialEditAttendeeDTO $data, ?string $status): AttendeeDomainObject { $attendee = $this->attendeeRepository->findFirstWhere([ 'id' => $data->attendee_id, @@ -62,23 +75,14 @@ private function updateAttendee(PartialEditAttendeeDTO $data): AttendeeDomainObj throw new ResourceNotFoundException; } - $status = $data->status ? strtoupper($data->status) : null; - - $statusIsUpdated = $status && $status !== $attendee->getStatus(); + $wasCancelled = $attendee->getStatus() === AttendeeStatus::CANCELLED->name; - if ($statusIsUpdated) { - $this->adjustSeatClaim($status, $attendee); - $this->adjustProductQuantity($status, $attendee); - $this->adjustEventStatistics($status, $attendee); + if ($status !== null && $wasCancelled && $status !== AttendeeStatus::CANCELLED->name) { + $this->reactivate($status, $attendee); } - if ($statusIsUpdated && $status === AttendeeStatus::CANCELLED->name) { - $this->domainEventDispatcherService->dispatch( - new AttendeeEvent( - type: DomainEventType::ATTENDEE_CANCELLED, - attendeeId: $attendee->getId(), - ) - ); + if ($status === AttendeeStatus::CANCELLED->name && ! $wasCancelled) { + $this->cancel($attendee); } return $this->attendeeRepository->updateByIdWhere( @@ -96,33 +100,91 @@ private function updateAttendee(PartialEditAttendeeDTO $data): AttendeeDomainObj /** * @throws ValidationException + * @throws Throwable */ - private function adjustSeatClaim(string $status, AttendeeDomainObject $attendee): void + private function reactivate(string $status, AttendeeDomainObject $attendee): void { - if ($status === AttendeeStatus::CANCELLED->name) { - $this->seatClaimService->releaseForAttendee($attendee->getId()); + $seatedOrder = $this->assertSeatAllowsReactivation($status, $attendee); + + $this->assertRoomToReactivate($attendee); - return; + if ($seatedOrder !== null) { + $this->reclaimSeat($attendee, $seatedOrder); } + $this->productQuantityService->increaseQuantitySold($attendee->getProductPriceId(), 1, $attendee->getEventOccurrenceId()); + + event(new CapacityChangedEvent( + eventId: $attendee->getEventId(), + direction: CapacityChangeDirection::DECREASED, + productId: $attendee->getProductId(), + productPriceId: $attendee->getProductPriceId(), + eventOccurrenceId: $attendee->getEventOccurrenceId(), + )); + + $order = $this->findOrderForStatistics($attendee); + if ($order !== null) { + $this->eventStatisticsReactivationService->incrementForReactivatedAttendee( + eventId: $attendee->getEventId(), + orderDate: $order->getCreatedAt(), + occurrenceId: $attendee->getEventOccurrenceId(), + ); + } + } + + /** + * @throws Throwable + */ + private function cancel(AttendeeDomainObject $attendee): void + { + $this->seatClaimService->releaseForAttendee($attendee->getId()); + + $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId(), 1, $attendee->getEventOccurrenceId()); + + event(new CapacityChangedEvent( + eventId: $attendee->getEventId(), + direction: CapacityChangeDirection::INCREASED, + productId: $attendee->getProductId(), + productPriceId: $attendee->getProductPriceId(), + eventOccurrenceId: $attendee->getEventOccurrenceId(), + )); + + $order = $this->findOrderForStatistics($attendee); + if ($order !== null) { + $this->eventStatisticsCancellationService->decrementForCancelledAttendee( + eventId: $attendee->getEventId(), + orderDate: $order->getCreatedAt(), + occurrenceId: $attendee->getEventOccurrenceId(), + ); + } + + $this->domainEventDispatcherService->dispatch( + new AttendeeEvent( + type: DomainEventType::ATTENDEE_CANCELLED, + attendeeId: $attendee->getId(), + ) + ); + } + + /** + * @throws ValidationException + */ + private function assertSeatAllowsReactivation(string $status, AttendeeDomainObject $attendee): ?OrderDomainObject + { if ($attendee->getSeatUid() === null) { - if ($status === AttendeeStatus::ACTIVE->name && $this->seatedProductLookupService->isSeated($attendee->getProductId())) { + if ($this->seatedProductLookupService->isSeated($attendee->getProductId())) { throw ValidationException::withMessages([ 'status' => __('This ticket now needs a seat, so this attendee cannot be reactivated. Add them again and choose a seat.'), ]); } - return; + return null; } if ($status !== AttendeeStatus::ACTIVE->name) { - if ($attendee->getStatus() === AttendeeStatus::CANCELLED->name) { - throw ValidationException::withMessages([ - 'status' => __('A cancelled seated attendee can only be reactivated as active'), - ]); - } - - return; + throw ValidationException::withMessages([ + 'status' => __('A cancelled seated attendee can only be reactivated as active'), + ]); } $order = $this->orderRepository->findById($attendee->getOrderId()); @@ -132,51 +194,78 @@ private function adjustSeatClaim(string $status, AttendeeDomainObject $attendee) ]); } - try { - $this->seatClaimService->reclaimForAttendee($attendee, $order); - } catch (SeatsUnavailableException) { + return $order; + } + + /** + * @throws ValidationException + */ + private function assertRoomToReactivate(AttendeeDomainObject $attendee): void + { + if ($attendee->getEventOccurrenceId() !== null) { + $this->assertDateHasRoom($attendee); + } + + $availability = $this->availableProductQuantitiesFetchService->getAvailableProductQuantities( + $attendee->getEventId(), + ignoreCache: true, + eventOccurrenceId: $attendee->getEventOccurrenceId(), + applyOccurrenceLimits: false, + ); + + if ($availability->firstOverflowingPool([$attendee->getProductId() => 1]) !== null) { throw ValidationException::withMessages([ - 'status' => __('Seat :seat has since been given to someone else', ['seat' => $attendee->getSeatLabel()]), + 'status' => __('The capacity shared by this ticket is full.'), + ]); + } + + $price = $availability->productQuantities->firstWhere('price_id', $attendee->getProductPriceId()); + $available = $attendee->getSeatUid() === null + ? $price?->quantity_available + : ($price?->quantity_available_before_seats ?? $price?->quantity_available); + + if (($available ?? 0) <= 0) { + throw ValidationException::withMessages([ + 'status' => __('This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.'), ]); - } catch (SeatSelectionInvalidException $exception) { - throw ValidationException::withMessages(['status' => $exception->getMessage()]); } } /** - * @todo - we should check product availability before updating the product quantity + * @throws ValidationException */ - private function adjustProductQuantity(string $status, AttendeeDomainObject $attendee): void + private function assertDateHasRoom(AttendeeDomainObject $attendee): void { - if ($status === AttendeeStatus::ACTIVE->name) { - $this->productQuantityService->increaseQuantitySold($attendee->getProductPriceId(), 1, $attendee->getEventOccurrenceId()); - - event(new CapacityChangedEvent( - eventId: $attendee->getEventId(), - direction: CapacityChangeDirection::DECREASED, - productId: $attendee->getProductId(), - productPriceId: $attendee->getProductPriceId(), - eventOccurrenceId: $attendee->getEventOccurrenceId(), - )); - } elseif ($status === AttendeeStatus::CANCELLED->name) { - $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId(), 1, $attendee->getEventOccurrenceId()); - - event(new CapacityChangedEvent( + try { + $this->occurrenceEligibilityService->assertOccurrencePurchasable( eventId: $attendee->getEventId(), - direction: CapacityChangeDirection::INCREASED, - productId: $attendee->getProductId(), - productPriceId: $attendee->getProductPriceId(), - eventOccurrenceId: $attendee->getEventOccurrenceId(), - )); + occurrenceId: $attendee->getEventOccurrenceId(), + allowPastOccurrence: true, + ); + } catch (ValidationException $exception) { + throw ValidationException::withMessages([ + 'status' => collect($exception->errors())->flatten()->first(), + ]); } } /** - * Adjust event statistics when attendee status changes - * - * @throws Throwable + * @throws ValidationException */ - private function adjustEventStatistics(string $status, AttendeeDomainObject $attendee): void + private function reclaimSeat(AttendeeDomainObject $attendee, OrderDomainObject $order): void + { + try { + $this->seatClaimService->reclaimForAttendee($attendee, $order); + } catch (SeatsUnavailableException) { + throw ValidationException::withMessages([ + 'status' => __('Seat :seat has since been given to someone else', ['seat' => $attendee->getSeatLabel()]), + ]); + } catch (SeatSelectionInvalidException $exception) { + throw ValidationException::withMessages(['status' => $exception->getMessage()]); + } + } + + private function findOrderForStatistics(AttendeeDomainObject $attendee): ?OrderDomainObject { $order = $this->orderRepository->findFirstWhere([ 'id' => $attendee->getOrderId(), @@ -189,22 +278,8 @@ private function adjustEventStatistics(string $status, AttendeeDomainObject $att 'order_id' => $attendee->getOrderId(), 'event_id' => $attendee->getEventId(), ]); - - return; } - if ($status === AttendeeStatus::CANCELLED->name) { - $this->eventStatisticsCancellationService->decrementForCancelledAttendee( - eventId: $attendee->getEventId(), - orderDate: $order->getCreatedAt(), - occurrenceId: $attendee->getEventOccurrenceId(), - ); - } elseif ($status === AttendeeStatus::ACTIVE->name) { - $this->eventStatisticsReactivationService->incrementForReactivatedAttendee( - eventId: $attendee->getEventId(), - orderDate: $order->getCreatedAt(), - occurrenceId: $attendee->getEventOccurrenceId(), - ); - } + return $order; } } diff --git a/backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php b/backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php index e27c64269c..e6b8da2951 100644 --- a/backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php +++ b/backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php @@ -2,6 +2,8 @@ namespace HiEvents\Services\Application\Handlers\Auth; +use HiEvents\Enterprise\Sso\Exceptions\SsoRequiredException; +use HiEvents\Enterprise\Sso\Services\Domain\SsoEnforcementService; use HiEvents\Exceptions\InvitationSignInRequiredException; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Services\Application\Handlers\Auth\DTO\AcceptInvitationDTO; @@ -17,6 +19,7 @@ class AcceptInvitationHandler public function __construct( private readonly InvitationAcceptanceService $invitationAcceptanceService, private readonly PasswordLoginPolicy $passwordLoginPolicy, + private readonly SsoEnforcementService $ssoEnforcementService, private readonly DatabaseManager $databaseManager, ) {} @@ -25,6 +28,7 @@ public function __construct( * @throws EncryptedPayloadExpiredException * @throws ResourceConflictException * @throws InvitationSignInRequiredException + * @throws SsoRequiredException * @throws Throwable */ public function handle(AcceptInvitationDTO $invitationData): void @@ -36,6 +40,14 @@ public function handle(AcceptInvitationDTO $invitationData): void throw new InvitationSignInRequiredException(__('Sign in to accept this invitation.')); } + if ($this->ssoEnforcementService->requiresSsoToJoin( + $invitation->user->getId(), + $invitation->user->getEmail(), + $invitation->accountId, + )) { + throw new SsoRequiredException($invitation->user->getEmail()); + } + if ($this->passwordLoginPolicy->isDisabled()) { throw new ResourceConflictException(__('Password sign-in is turned off. Use one of the sign-in options instead.')); } diff --git a/backend/app/Services/Application/Handlers/Auth/DTO/InvitationDetailsDTO.php b/backend/app/Services/Application/Handlers/Auth/DTO/InvitationDetailsDTO.php new file mode 100644 index 0000000000..072a293266 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Auth/DTO/InvitationDetailsDTO.php @@ -0,0 +1,14 @@ +invitationAcceptanceService->findPending($invitationToken); + + return new InvitationDetailsDTO( + invitation: $invitation, + ssoRequired: $this->ssoEnforcementService->requiresSsoToJoin( + $invitation->user->getId(), + $invitation->user->getEmail(), + $invitation->accountId, + ), + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Auth/Sso/DTO/SsoSignInMethodsResult.php b/backend/app/Services/Application/Handlers/Auth/Sso/DTO/SsoSignInMethodsResult.php new file mode 100644 index 0000000000..dd9087969c --- /dev/null +++ b/backend/app/Services/Application/Handlers/Auth/Sso/DTO/SsoSignInMethodsResult.php @@ -0,0 +1,20 @@ + $identities + * @param Collection $linkableConnections + */ + public function __construct( + public readonly Collection $identities, + public readonly Collection $linkableConnections, + ) {} +} diff --git a/backend/app/Services/Application/Handlers/Auth/Sso/ExchangeSsoCodeHandler.php b/backend/app/Services/Application/Handlers/Auth/Sso/ExchangeSsoCodeHandler.php index 9e95fe290d..c41639cc94 100644 --- a/backend/app/Services/Application/Handlers/Auth/Sso/ExchangeSsoCodeHandler.php +++ b/backend/app/Services/Application/Handlers/Auth/Sso/ExchangeSsoCodeHandler.php @@ -11,6 +11,7 @@ use HiEvents\Enterprise\Sso\Services\Domain\AccountSsoConnectionService; use HiEvents\Enterprise\Sso\Services\Domain\AccountSsoDomainService; use HiEvents\Enterprise\Sso\Services\Domain\EnterpriseSsoResolver; +use HiEvents\Enterprise\Sso\Services\Domain\SsoConnectionLinkService; use HiEvents\Enterprise\Sso\Services\Domain\SsoEnforcementService; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Exceptions\SsoException; @@ -57,6 +58,7 @@ public function __construct( private readonly AccountSsoConnectionService $connectionService, private readonly AccountSsoDomainService $domainService, private readonly SsoEnforcementService $enforcementService, + private readonly SsoConnectionLinkService $connectionLinkService, ) {} /** @@ -287,6 +289,10 @@ private function link(SsoProviderConfig $provider, SsoStateDTO $state, SsoIdenti { $this->assertSameSession($state, $dto); + if ($provider->isAccountConnection()) { + $this->connectionLinkService->assertCanLink($provider, (int) $state->userId); + } + $this->userIdentityService->attach($this->userRepository->findById((int) $state->userId), $claims); return new SsoExchangeResult( @@ -307,7 +313,8 @@ private function reauthenticate(SsoProviderConfig $provider, SsoStateDTO $state, $isFresh = $claims->authTime === null || (now()->getTimestamp() - $claims->authTime) <= self::REAUTH_MAX_AGE_SECONDS; $connectionCannotConfirm = $provider->isAccountConnection() - && $this->enterpriseResolver->hasMembershipsOutside((int) $state->userId, (int) $provider->accountId); + && ($this->enterpriseResolver->hasMembershipsOutside((int) $state->userId, (int) $provider->accountId) + || $this->connectionLinkService->isSuperAdmin((int) $state->userId)); if ($identity === null || $identity->getUserId() !== $state->userId || ! $isFresh || $connectionCannotConfirm) { throw new SsoException(SsoErrorCode::REAUTH_FAILED, __('We couldn\'t confirm it\'s you. Sign in with an account you have already connected.')); diff --git a/backend/app/Services/Application/Handlers/Auth/Sso/GetSsoIdentitiesHandler.php b/backend/app/Services/Application/Handlers/Auth/Sso/GetSsoIdentitiesHandler.php index 98ce68f0e8..25c0cda74f 100644 --- a/backend/app/Services/Application/Handlers/Auth/Sso/GetSsoIdentitiesHandler.php +++ b/backend/app/Services/Application/Handlers/Auth/Sso/GetSsoIdentitiesHandler.php @@ -2,25 +2,26 @@ namespace HiEvents\Services\Application\Handlers\Auth\Sso; -use HiEvents\DomainObjects\UserIdentityDomainObject; +use HiEvents\Enterprise\Sso\Services\Domain\SsoConnectionLinkService; use HiEvents\Repository\Eloquent\Value\OrderAndDirection; use HiEvents\Repository\Interfaces\UserIdentityRepositoryInterface; -use Illuminate\Support\Collection; +use HiEvents\Services\Application\Handlers\Auth\Sso\DTO\SsoSignInMethodsResult; class GetSsoIdentitiesHandler { public function __construct( private readonly UserIdentityRepositoryInterface $userIdentityRepository, + private readonly SsoConnectionLinkService $connectionLinkService, ) {} - /** - * @return Collection - */ - public function handle(int $userId): Collection + public function handle(int $userId): SsoSignInMethodsResult { - return $this->userIdentityRepository->findWhere( - where: ['user_id' => $userId], - orderAndDirections: [new OrderAndDirection('created_at', 'asc')], + return new SsoSignInMethodsResult( + identities: $this->userIdentityRepository->findWhere( + where: ['user_id' => $userId], + orderAndDirections: [new OrderAndDirection('created_at', 'asc')], + ), + linkableConnections: $this->connectionLinkService->linkableConnections($userId), ); } } diff --git a/backend/app/Services/Application/Handlers/Auth/Sso/GetSsoProvidersHandler.php b/backend/app/Services/Application/Handlers/Auth/Sso/GetSsoProvidersHandler.php index 1531549959..2a8dcfaab3 100644 --- a/backend/app/Services/Application/Handlers/Auth/Sso/GetSsoProvidersHandler.php +++ b/backend/app/Services/Application/Handlers/Auth/Sso/GetSsoProvidersHandler.php @@ -17,7 +17,7 @@ public function __construct( ) {} /** - * @return array{providers: array, password_login_enabled: bool, registration_enabled: bool, buttons_visible: bool, enterprise_sso_enabled: bool} + * @return array{providers: array, password_login_enabled: bool, registration_enabled: bool, enterprise_sso_enabled: bool} */ public function handle(): array { @@ -33,7 +33,6 @@ public function handle(): array )), 'password_login_enabled' => ! $this->providerRegistry->isPasswordLoginDisabled(), 'registration_enabled' => ! $this->config->get('app.disable_registration'), - 'buttons_visible' => $this->providerRegistry->areButtonsVisible(), 'enterprise_sso_enabled' => $this->connectionRepository->countWhere(['status' => AccountSsoConnectionStatus::ACTIVE->value]) > 0, ]; } diff --git a/backend/app/Services/Application/Handlers/Auth/Sso/StartSsoHandler.php b/backend/app/Services/Application/Handlers/Auth/Sso/StartSsoHandler.php index 1a96c7a4c5..231f4d63e9 100644 --- a/backend/app/Services/Application/Handlers/Auth/Sso/StartSsoHandler.php +++ b/backend/app/Services/Application/Handlers/Auth/Sso/StartSsoHandler.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\Enums\SsoErrorCode; use HiEvents\DomainObjects\Enums\SsoIntent; use HiEvents\DomainObjects\Enums\SsoTicketType; +use HiEvents\Enterprise\Sso\Services\Domain\SsoConnectionLinkService; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Exceptions\SsoException; use HiEvents\Services\Application\Handlers\Auth\Sso\DTO\StartSsoDTO; @@ -27,6 +28,7 @@ public function __construct( private readonly SsoTicketService $ticketService, private readonly OidcClient $oidcClient, private readonly InvitationAcceptanceService $invitationAcceptanceService, + private readonly SsoConnectionLinkService $connectionLinkService, ) {} /** @@ -44,6 +46,10 @@ public function handle(StartSsoDTO $dto): SsoStartResult $this->assertInvitationIsPending($dto->invitationToken); } + if ($dto->intent === SsoIntent::LINK && $provider->isAccountConnection()) { + $this->connectionLinkService->assertCanLink($provider, (int) $dto->userId); + } + $binding = SsoTicketService::newSecret(); $nonce = SsoTicketService::newSecret(); $codeVerifier = SsoTicketService::newSecret(); diff --git a/backend/app/Services/Application/Handlers/Lineup/DTO/EventLineupDTO.php b/backend/app/Services/Application/Handlers/Lineup/DTO/EventLineupDTO.php index 6489f16916..7e73e12ed9 100644 --- a/backend/app/Services/Application/Handlers/Lineup/DTO/EventLineupDTO.php +++ b/backend/app/Services/Application/Handlers/Lineup/DTO/EventLineupDTO.php @@ -14,5 +14,6 @@ public function __construct( public readonly Collection $spaces, public readonly Collection $schedule_items, public readonly ?Collection $venues = null, + public readonly ?array $schedule_days_by_entry_id = null, ) {} } diff --git a/backend/app/Services/Application/Handlers/Lineup/GetEventLineupHandler.php b/backend/app/Services/Application/Handlers/Lineup/GetEventLineupHandler.php index 5b48238697..97787707f3 100644 --- a/backend/app/Services/Application/Handlers/Lineup/GetEventLineupHandler.php +++ b/backend/app/Services/Application/Handlers/Lineup/GetEventLineupHandler.php @@ -21,10 +21,12 @@ use HiEvents\Repository\Eloquent\Value\Relationship; 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; use HiEvents\Services\Application\Handlers\Lineup\DTO\EventLineupDTO; +use HiEvents\Services\Domain\Lineup\ScheduleDay; use Illuminate\Support\Collection; class GetEventLineupHandler @@ -35,6 +37,7 @@ public function __construct( private readonly EventScheduleItemRepositoryInterface $scheduleItemRepository, private readonly EventLocationRepositoryInterface $eventLocationRepository, private readonly EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly EventRepositoryInterface $eventRepository, ) {} public function handle(int $eventId, bool $forPublic): EventLineupDTO @@ -132,6 +135,38 @@ private function publicLineup(int $eventId, Collection $entries, Collection $spa entries: $visibleEntries, spaces: $showSchedule ? $spaces : collect(), schedule_items: $visibleItems, + schedule_days_by_entry_id: $showSchedule ? null : $this->scheduleDaysByEntryId($eventId, $visibleEntries, $scheduleItems), ); } + + /** + * @return array>|null + */ + private function scheduleDaysByEntryId(int $eventId, Collection $entries, Collection $scheduleItems): ?array + { + $event = $this->eventRepository->findById($eventId); + + if (! $event->isRecurring()) { + return null; + } + + $days = $entries->mapWithKeys(fn (EventLineupEntryDomainObject $entry) => [$entry->getId() => []])->all(); + + if ($days === [] || $scheduleItems->isEmpty()) { + return $days; + } + + $timezone = $event->getTimezone() ?? 'UTC'; + + foreach ($scheduleItems as $item) { + $day = ScheduleDay::keyForUtc($item->getStartsAt(), $timezone); + foreach ($item->getPerformers() ?? [] as $performer) { + if (array_key_exists($performer->getEventLineupEntryId(), $days)) { + $days[$performer->getEventLineupEntryId()][$day] = $day; + } + } + } + + return array_map(fn (array $entryDays) => collect($entryDays)->sort()->values()->all(), $days); + } } diff --git a/backend/app/Services/Application/Handlers/Lineup/ImportLineupHandler.php b/backend/app/Services/Application/Handlers/Lineup/ImportLineupHandler.php index dd6cccb6e9..f65ae0c0df 100644 --- a/backend/app/Services/Application/Handlers/Lineup/ImportLineupHandler.php +++ b/backend/app/Services/Application/Handlers/Lineup/ImportLineupHandler.php @@ -32,6 +32,7 @@ use HiEvents\Services\Application\Handlers\Lineup\DTO\ImportLineupSlotDTO; use HiEvents\Services\Domain\Event\EventSpamCheckDispatchService; use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; +use HiEvents\Services\Infrastructure\Lock\TransactionLockService; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -78,6 +79,7 @@ public function __construct( private readonly HtmlPurifierService $purifier, private readonly EventSpamCheckDispatchService $spamCheckDispatchService, private readonly DatabaseManager $databaseManager, + private readonly TransactionLockService $transactionLockService, ) {} /** @@ -119,7 +121,8 @@ public function handle(ImportLineupDTO $dto): ImportLineupResultDTO private function load(int $eventId): void { - $this->event = $this->eventRepository->findByIdLocked($eventId); + $this->transactionLockService->lockEventLineup($eventId); + $this->event = $this->eventRepository->findById($eventId); $this->timezone = $this->event->getTimezone() ?? 'UTC'; $this->performerRows = []; $this->updatedProfileIds = []; diff --git a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php index dca7752619..0a1051a369 100644 --- a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php +++ b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php @@ -82,7 +82,7 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject $event = $this->eventRepository->findById($messageData->event_id); - $scheduledAtUtc = $messageData->scheduled_at + $scheduledAtUtc = $isScheduled ? DateHelper::convertToUTC($messageData->scheduled_at, $event->getTimezone()) : null; diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php index 8a6d11c128..45bb681638 100644 --- a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php +++ b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\Order; +use HiEvents\DomainObjects\AttendeeCheckInDomainObject; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventLocationDomainObject; @@ -95,6 +96,7 @@ private function getOrderDomainObject(GetOrderPublicDTO $getOrderData): ?OrderDo ], name: 'event_occurrence', ), + new Relationship(domainObject: AttendeeCheckInDomainObject::class, name: 'check_ins'), ], )) ->loadRelation(new Relationship(domainObject: InvoiceDomainObject::class)) diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php index 9e6598452d..aed46497c5 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php @@ -6,26 +6,22 @@ use Brick\Math\Exception\NumberFormatException; use Brick\Math\Exception\RoundingNecessaryException; use Brick\Money\Exception\UnknownCurrencyException; -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\Exceptions\RefundNotPossibleException; -use HiEvents\Mail\Order\OrderRefunded; use HiEvents\Repository\Eloquent\Value\Relationship; -use HiEvents\Repository\Interfaces\EventRepositoryInterface; +use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Application\Handlers\Order\DTO\RefundOrderDTO; use HiEvents\Services\Domain\Order\OrderCancelService; use HiEvents\Services\Domain\Payment\Stripe\StripePaymentIntentRefundService; use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory; use HiEvents\Values\MoneyValue; -use Illuminate\Contracts\Mail\Mailer; use Illuminate\Database\DatabaseManager; use Stripe\Exception\ApiErrorException; +use Stripe\Refund; use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Throwable; @@ -34,8 +30,7 @@ class RefundOrderHandler public function __construct( private readonly StripePaymentIntentRefundService $refundService, private readonly OrderRepositoryInterface $orderRepository, - private readonly EventRepositoryInterface $eventRepository, - private readonly Mailer $mailer, + private readonly OrderRefundRepositoryInterface $orderRefundRepository, private readonly OrderCancelService $orderCancelService, private readonly DatabaseManager $databaseManager, private readonly StripeClientFactory $stripeClientFactory, @@ -84,20 +79,6 @@ private function validateRefundability(OrderDomainObject $order): void } } - private function notifyBuyer(OrderDomainObject $order, EventDomainObject $event, MoneyValue $amount): void - { - $this->mailer - ->to($order->getEmail()) - ->locale($order->getLocale()) - ->send(new OrderRefunded( - order: $order, - event: $event, - organizer: $event->getOrganizer(), - eventSettings: $event->getEventSettings(), - refundAmount: $amount - )); - } - private function markOrderRefundPending(OrderDomainObject $order): OrderDomainObject { return $this->orderRepository->updateFromArray( @@ -108,6 +89,14 @@ private function markOrderRefundPending(OrderDomainObject $order): OrderDomainOb ); } + private function countFailedRefunds(OrderDomainObject $order): int + { + return $this->orderRefundRepository->countWhere([ + 'order_id' => $order->getId(), + ['status', 'in', [Refund::STATUS_FAILED, Refund::STATUS_CANCELED]], + ]); + } + /** * @throws ApiErrorException * @throws UnknownCurrencyException @@ -120,10 +109,6 @@ private function markOrderRefundPending(OrderDomainObject $order): OrderDomainOb private function refundOrder(RefundOrderDTO $refundOrderDTO): OrderDomainObject { $order = $this->fetchOrder($refundOrderDTO->event_id, $refundOrderDTO->order_id); - $event = $this->eventRepository - ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) - ->loadRelation(EventSettingDomainObject::class) - ->findById($refundOrderDTO->event_id); $amount = MoneyValue::fromFloat($refundOrderDTO->amount, $order->getCurrency()); @@ -141,14 +126,13 @@ private function refundOrder(RefundOrderDTO $refundOrderDTO): OrderDomainObject $this->refundService->refundPayment( amount: $amount, + alreadyRefunded: MoneyValue::fromFloat($order->getTotalRefunded(), $order->getCurrency()), + failedRefundCount: $this->countFailedRefunds($order), payment: $order->getStripePayment(), - stripeClient: $stripeClient + stripeClient: $stripeClient, + notifyBuyer: $refundOrderDTO->notify_buyer && $order->getEmail() !== null, ); - if ($refundOrderDTO->notify_buyer && $order->getEmail() !== null) { - $this->notifyBuyer($order, $event, $amount); - } - return $pendingOrder; } } diff --git a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php index 54b4cf71bc..5f42301619 100644 --- a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php @@ -38,15 +38,14 @@ public function handle(PartialUpdateOrganizerSettingsDTO $dto): OrganizerSetting $organizerSettings->getDefaultAttendeeDetailsCollectionMethod() )?->name ?? $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(), - 'default_show_marketing_opt_in' => $dto->getProvided( - 'defaultShowMarketingOptIn', - $organizerSettings->getDefaultShowMarketingOptIn() - ), - - 'default_pass_platform_fee_to_buyer' => $dto->getProvided( - 'defaultPassPlatformFeeToBuyer', - $organizerSettings->getDefaultPassPlatformFeeToBuyer() - ), + 'default_show_marketing_opt_in' => $dto->getProvided('defaultShowMarketingOptIn') + ?? $organizerSettings->getDefaultShowMarketingOptIn(), + + 'default_pass_platform_fee_to_buyer' => $dto->getProvided('defaultPassPlatformFeeToBuyer') + ?? $organizerSettings->getDefaultPassPlatformFeeToBuyer(), + + 'default_allow_attendee_self_edit' => $dto->getProvided('defaultAllowAttendeeSelfEdit') + ?? $organizerSettings->getDefaultAllowAttendeeSelfEdit(), 'social_media_handles' => array_filter([ 'facebook' => $dto->getProvided('facebookHandle', $organizerSettings->getSocialMediaHandle('facebook')), diff --git a/backend/app/Services/Application/Handlers/TaxAndFee/DTO/UpsertTaxDTO.php b/backend/app/Services/Application/Handlers/TaxAndFee/DTO/UpsertTaxDTO.php index 11d8494b13..65285f216a 100644 --- a/backend/app/Services/Application/Handlers/TaxAndFee/DTO/UpsertTaxDTO.php +++ b/backend/app/Services/Application/Handlers/TaxAndFee/DTO/UpsertTaxDTO.php @@ -10,13 +10,13 @@ class UpsertTaxDTO extends BaseDTO { public function __construct( public readonly string $name, - public readonly ?string $description, public readonly TaxCalculationType $calculation_type, public readonly TaxType $type, public readonly float $rate, public readonly bool $is_active, public readonly bool $is_default, public readonly int $account_id, + public readonly ?string $description = null, public readonly ?int $id = null, ) {} } diff --git a/backend/app/Services/Application/Handlers/User/DTO/UpdateUserDTO.php b/backend/app/Services/Application/Handlers/User/DTO/UpdateUserDTO.php index 9216506542..bb19637971 100644 --- a/backend/app/Services/Application/Handlers/User/DTO/UpdateUserDTO.php +++ b/backend/app/Services/Application/Handlers/User/DTO/UpdateUserDTO.php @@ -13,9 +13,9 @@ public function __construct( public readonly int $id, public readonly int $account_id, public readonly string $first_name, - public readonly string $last_name, - public readonly Role $role, public readonly int $updated_by_user_id, + public readonly ?string $last_name = null, + public readonly ?Role $role = null, public readonly ?UserStatus $status = null, public readonly ?AccountUserAccessData $access = null, ) {} diff --git a/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php b/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php index baf1fd4aaf..b4c009c096 100644 --- a/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php +++ b/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php @@ -49,7 +49,7 @@ public function handle(UpdateMeDTO $updateUserData): UserDomainObject } if ($this->isUpdatingDetails($updateUserData)) { - $updateArray = [ + $updateArray += [ 'first_name' => $updateUserData->first_name, 'last_name' => $updateUserData->last_name, 'timezone' => $updateUserData->timezone, diff --git a/backend/app/Services/Application/Handlers/User/UpdateUserHandler.php b/backend/app/Services/Application/Handlers/User/UpdateUserHandler.php index 92f5174f46..ff3f9f0b13 100644 --- a/backend/app/Services/Application/Handlers/User/UpdateUserHandler.php +++ b/backend/app/Services/Application/Handlers/User/UpdateUserHandler.php @@ -60,7 +60,7 @@ private function updateUser(UpdateUserDTO $updateUserData): UserDomainObject ] ); - if ($updateUserData->role !== Role::ADMIN && $accountUser->getIsAccountOwner()) { + if ($updateUserData->role !== null && $updateUserData->role !== Role::ADMIN && $accountUser->getIsAccountOwner()) { throw new CannotUpdateResourceException(__( 'You cannot update the role of the account owner' )); @@ -90,25 +90,29 @@ private function updateUser(UpdateUserDTO $updateUserData): UserDomainObject ); } - $this->accountUserRepository->updateWhere( - attributes: array_filter([ - 'role' => $updateUserData->role->name, - 'status' => $updateUserData->status?->name, - ]), - where: [ - 'user_id' => $updateUserData->id, - 'account_id' => $updateUserData->account_id, - ] - ); + $accountUserAttributes = array_filter([ + 'role' => $updateUserData->role?->name, + 'status' => $updateUserData->status?->name, + ]); + + if ($accountUserAttributes !== []) { + $this->accountUserRepository->updateWhere( + attributes: $accountUserAttributes, + where: [ + 'user_id' => $updateUserData->id, + 'account_id' => $updateUserData->account_id, + ] + ); + } if ($updateUserData->status === UserStatus::INACTIVE) { $this->apiKeyService->revokeAllForMember($updateUserData->id, $updateUserData->account_id); } - if ($updateUserData->access !== null || ! $updateUserData->role->canBeRestricted()) { + if ($updateUserData->access !== null || ($updateUserData->role !== null && ! $updateUserData->role->canBeRestricted())) { $this->accessUpdateService->update( accountUser: $accountUser, - role: $updateUserData->role, + role: $updateUserData->role ?? Role::fromName($accountUser->getRole()), access: $updateUserData->access ?? new AccountUserAccessData, ); } diff --git a/backend/app/Services/Application/Handlers/Widget/UpsertWidgetHandler.php b/backend/app/Services/Application/Handlers/Widget/UpsertWidgetHandler.php index 6dd5d04802..0c99d03209 100644 --- a/backend/app/Services/Application/Handlers/Widget/UpsertWidgetHandler.php +++ b/backend/app/Services/Application/Handlers/Widget/UpsertWidgetHandler.php @@ -4,10 +4,13 @@ namespace HiEvents\Services\Application\Handlers\Widget; +use Closure; use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductCategoryDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\Generated\WidgetDomainObjectAbstract; +use HiEvents\DomainObjects\ProductCategoryDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\WidgetDomainObject; use HiEvents\Exceptions\InvalidWidgetSettingsException; use HiEvents\Exceptions\ResourceNotFoundException; @@ -35,14 +38,12 @@ public function __construct( */ public function handle(UpsertWidgetDTO $dto): WidgetDomainObject { - if ($dto->widget_id !== null) { - $this->assertWidgetExists($dto); - } + $storedSettings = $dto->widget_id === null ? [] : $this->findWidget($dto)->getSettings(); $settings = array_filter($dto->settings, static fn ($value) => $value !== null); if ($dto->event_id !== null) { - $this->assertSettingsBelongToEvent($dto->event_id, $settings); + $settings = $this->withEventSelections($dto->event_id, $settings, $storedSettings); } $attributes = [ @@ -67,7 +68,7 @@ public function handle(UpsertWidgetDTO $dto): WidgetDomainObject /** * @throws ResourceNotFoundException */ - private function assertWidgetExists(UpsertWidgetDTO $dto): void + private function findWidget(UpsertWidgetDTO $dto): WidgetDomainObject { $widget = $this->widgetRepository->findFirstWhere([ WidgetDomainObjectAbstract::ID => $dto->widget_id, @@ -78,36 +79,87 @@ private function assertWidgetExists(UpsertWidgetDTO $dto): void if ($widget === null) { throw new ResourceNotFoundException(__('Widget not found')); } + + return $widget; + } + + /** + * @throws InvalidWidgetSettingsException + */ + private function withEventSelections(int $eventId, array $settings, array $storedSettings): array + { + $settings = $this->withoutStaleIds( + settings: $settings, + storedSettings: $storedSettings, + key: 'products', + findExisting: fn (array $ids) => $this->productRepository->findWhereIn( + ProductDomainObjectAbstract::ID, + $ids, + [ProductDomainObjectAbstract::EVENT_ID => $eventId], + ), + message: __('One or more selected products do not belong to this event.'), + ); + + $settings = $this->withoutStaleIds( + settings: $settings, + storedSettings: $storedSettings, + key: 'categories', + findExisting: fn (array $ids) => $this->productCategoryRepository->findWhereIn( + ProductCategoryDomainObjectAbstract::ID, + $ids, + [ProductCategoryDomainObjectAbstract::EVENT_ID => $eventId], + ), + message: __('One or more selected categories do not belong to this event.'), + ); + + return $this->withoutStaleOccurrence($eventId, $settings, $storedSettings); } /** * @throws InvalidWidgetSettingsException */ - private function assertSettingsBelongToEvent(int $eventId, array $settings): void + private function withoutStaleIds(array $settings, array $storedSettings, string $key, Closure $findExisting, string $message): array { - $productIds = array_values(array_unique($settings['products'] ?? [])); - if ($productIds !== [] && $this->productRepository->findWhereIn( - ProductDomainObjectAbstract::ID, - $productIds, - [ProductDomainObjectAbstract::EVENT_ID => $eventId], - )->count() !== count($productIds)) { - throw new InvalidWidgetSettingsException('settings.products', __('One or more selected products do not belong to this event.')); + if (empty($settings[$key])) { + return $settings; + } + + $existingIds = $findExisting($settings[$key]) + ->map(static fn (ProductDomainObject|ProductCategoryDomainObject $domainObject) => $domainObject->getId()) + ->all(); + $missingIds = array_diff($settings[$key], $existingIds); + + if (array_diff($missingIds, $storedSettings[$key] ?? []) !== []) { + throw new InvalidWidgetSettingsException("settings.$key", $message); } - $categoryIds = array_values(array_unique($settings['categories'] ?? [])); - if ($categoryIds !== [] && $this->productCategoryRepository->findWhereIn( - ProductCategoryDomainObjectAbstract::ID, - $categoryIds, - [ProductCategoryDomainObjectAbstract::EVENT_ID => $eventId], - )->count() !== count($categoryIds)) { - throw new InvalidWidgetSettingsException('settings.categories', __('One or more selected categories do not belong to this event.')); + $settings[$key] = array_values(array_diff($settings[$key], $missingIds)); + + if ($settings[$key] === []) { + unset($settings[$key]); } - if (isset($settings['occurrence_id']) && $this->occurrenceRepository->findFirstWhere([ + return $settings; + } + + /** + * @throws InvalidWidgetSettingsException + */ + private function withoutStaleOccurrence(int $eventId, array $settings, array $storedSettings): array + { + if (! isset($settings['occurrence_id']) || $this->occurrenceRepository->findFirstWhere([ EventOccurrenceDomainObjectAbstract::ID => $settings['occurrence_id'], EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, - ]) === null) { + ]) !== null) { + return $settings; + } + + if ((int) $settings['occurrence_id'] !== (int) ($storedSettings['occurrence_id'] ?? 0)) { throw new InvalidWidgetSettingsException('settings.occurrence_id', __('The selected date does not belong to this event.')); } + + unset($settings['occurrence_id']); + + return $settings; } } diff --git a/backend/app/Services/Domain/Account/Anonymization/Anonymizers/OrganizerAnonymizer.php b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/OrganizerAnonymizer.php index 94b1edca8e..4d84b76c99 100644 --- a/backend/app/Services/Domain/Account/Anonymization/Anonymizers/OrganizerAnonymizer.php +++ b/backend/app/Services/Domain/Account/Anonymization/Anonymizers/OrganizerAnonymizer.php @@ -33,6 +33,20 @@ public function anonymize(AnonymizationContext $context): array ], context: $context, ), + $this->executor->scrub( + query: $this->databaseManager->table('organizer_settings')->whereIn('organizer_id', $context->organizerIds), + entity: 'organizer_settings', + columnStrategies: [ + 'homepage_sections' => AnonymizationStrategy::NULLIFY, + 'website_url' => AnonymizationStrategy::NULLIFY, + 'location_details' => AnonymizationStrategy::NULLIFY, + 'social_media_handles' => AnonymizationStrategy::NULLIFY, + 'seo_title' => AnonymizationStrategy::NULLIFY, + 'seo_description' => AnonymizationStrategy::NULLIFY, + 'seo_keywords' => AnonymizationStrategy::NULLIFY, + ], + context: $context, + ), $this->executor->scrub( query: $this->databaseManager->table('profiles')->whereIn('organizer_id', $context->organizerIds), entity: 'profiles', diff --git a/backend/app/Services/Domain/Auth/Access/AdminAccessNotificationService.php b/backend/app/Services/Domain/Auth/Access/AdminAccessNotificationService.php index 11e498ff5c..ce2e66216b 100644 --- a/backend/app/Services/Domain/Auth/Access/AdminAccessNotificationService.php +++ b/backend/app/Services/Domain/Auth/Access/AdminAccessNotificationService.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Domain\Auth\Access; use HiEvents\DomainObjects\AccountUserDomainObject; +use HiEvents\DomainObjects\Status\UserStatus; use HiEvents\Mail\Account\AdminAccessGrantedMail; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; use HiEvents\Repository\Interfaces\UserRepositoryInterface; @@ -30,12 +31,19 @@ public function notifyOwner(int $accountId, int $newAdminUserId, ?int $grantedBy $ownerUser = $this->userRepository->findById($owner->getUserId()); + /** @var AccountUserDomainObject|null $newAdmin */ + $newAdmin = $this->accountUserRepository->findFirstWhere([ + 'account_id' => $accountId, + 'user_id' => $newAdminUserId, + ]); + $this->mailer ->to($ownerUser->getEmail()) ->locale($ownerUser->getLocale()) ->send(new AdminAccessGrantedMail( newAdminEmail: $this->userRepository->findById($newAdminUserId)->getEmail(), grantedByEmail: $grantedByUserId === null ? null : $this->userRepository->findById($grantedByUserId)->getEmail(), + isInvitation: $newAdmin?->getStatus() === UserStatus::INVITED->name, )); } } diff --git a/backend/app/Services/Domain/Auth/LoginService.php b/backend/app/Services/Domain/Auth/LoginService.php index c9f38da069..cefc63eb1f 100644 --- a/backend/app/Services/Domain/Auth/LoginService.php +++ b/backend/app/Services/Domain/Auth/LoginService.php @@ -84,7 +84,11 @@ public function beginLogin( if ($this->twoFactorAuthenticationService->isEnabled($user) && ($this->ssoEnforcementService->requiresFreshSecondFactor($user->getId(), $context) || ! $this->trustedDeviceService->isTrusted($user->getId(), $trustedDeviceToken))) { - $this->assertCanSignInTo($user, $requestedAccountId); + $accountId = $this->resolveAccountToSignInTo($user, $requestedAccountId); + + if ($accountId !== null) { + $this->ssoEnforcementService->assertMemberSignInAllowed($user, $accountId, $context); + } return new LoginResponse( accounts: collect(), @@ -161,9 +165,9 @@ private function accountRequiredByContext(?int $requestedAccountId, AuthContext /** * @throws UnauthorizedException */ - private function assertCanSignInTo(UserDomainObject $user, ?int $requestedAccountId): void + private function resolveAccountToSignInTo(UserDomainObject $user, ?int $requestedAccountId): ?int { - $this->getAccountId( + return $this->getAccountId( $this->findActiveUserAccounts($user)->map(fn ($accountUser) => $accountUser->getAccount()), $requestedAccountId, ); diff --git a/backend/app/Services/Domain/Auth/Sso/SsoProviderRegistry.php b/backend/app/Services/Domain/Auth/Sso/SsoProviderRegistry.php index abc12b13bb..3df27208d8 100644 --- a/backend/app/Services/Domain/Auth/Sso/SsoProviderRegistry.php +++ b/backend/app/Services/Domain/Auth/Sso/SsoProviderRegistry.php @@ -64,11 +64,6 @@ public function isPasswordLoginDisabled(): bool return (bool) $this->config->get('sso.password_login_disabled'); } - public function areButtonsVisible(): bool - { - return (bool) $this->config->get('sso.buttons_visible', true); - } - private function build(string $key, array $settings): ?SsoProviderConfig { $clientId = $settings['client_id'] ?? null; diff --git a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php index d1bc949986..42d821b371 100644 --- a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php +++ b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php @@ -41,7 +41,7 @@ public function buildOrderConfirmationContext( $occurrence ??= $order->getSingleEventOccurrence(); $startDateRaw = $occurrence?->getStartDate() ?? $event->getStartDate(); - $endDateRaw = $occurrence?->getEndDate() ?? $event->getEndDate(); + $endDateRaw = $occurrence === null ? $event->getEndDate() : $occurrence->getEndDate(); $eventStartDate = $startDateRaw ? new Carbon(DateHelper::convertFromUTC($startDateRaw, $event->getTimezone())) : null; $eventEndDate = $endDateRaw ? new Carbon(DateHelper::convertFromUTC($endDateRaw, $event->getTimezone())) : null; diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 6116338e47..602814609a 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -271,7 +271,7 @@ private function createEventSettings( 'show_marketing_opt_in' => $organizerSettings->getDefaultShowMarketingOptIn(), 'allow_copy_details_to_all_attendees' => true, 'pass_platform_fee_to_buyer' => $organizerSettings->getDefaultPassPlatformFeeToBuyer(), - 'allow_attendee_self_edit' => $organizerSettings->getDefaultAllowAttendeeSelfEdit() ?? false, + 'allow_attendee_self_edit' => $organizerSettings->getDefaultAllowAttendeeSelfEdit(), 'ticket_design_settings' => [ 'accent_color' => $homepageThemeSettings['accent'] ?? '#333', ], diff --git a/backend/app/Services/Domain/Event/EventSpamCheckContentService.php b/backend/app/Services/Domain/Event/EventSpamCheckContentService.php index b162163657..6332d3abc1 100644 --- a/backend/app/Services/Domain/Event/EventSpamCheckContentService.php +++ b/backend/app/Services/Domain/Event/EventSpamCheckContentService.php @@ -2,9 +2,11 @@ namespace HiEvents\Services\Domain\Event; +use HiEvents\DomainObjects\Enums\LineupBillingTier; 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,11 +18,22 @@ use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Domain\Event\DTO\EventSpamCheckContentDTO; +use HiEvents\Services\Domain\Lineup\LineupHighlightSelector; +use Illuminate\Support\Collection; class EventSpamCheckContentService { + public const TICKET_EMAIL_LABEL_PREFIX = 'ticket email '; + + private const LINEUP_TIER_RANKS = [ + LineupBillingTier::HEADLINER->value => 0, + LineupBillingTier::FEATURED->value => 1, + LineupBillingTier::STANDARD->value => 2, + ]; + public function __construct( private readonly EventRepositoryInterface $eventRepository, + private readonly LineupHighlightSelector $lineupHighlightSelector, ) {} public function loadEvent(int $eventId): ?EventDomainObject @@ -38,7 +51,9 @@ public function loadEvent(int $eventId): ?EventDomainObject new Relationship(domainObject: ProfileDomainObject::class, name: 'profile'), ])) ->loadRelation(new Relationship(domainObject: EventSpaceDomainObject::class)) - ->loadRelation(new Relationship(domainObject: EventScheduleItemDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventScheduleItemDomainObject::class, nested: [ + new Relationship(domainObject: EventScheduleItemPerformerDomainObject::class, name: 'performers'), + ])) ->findFirstWhere(['id' => $eventId]); return $event; @@ -46,21 +61,117 @@ public function loadEvent(int $eventId): ?EventDomainObject public function buildForEvent(EventDomainObject $event): EventSpamCheckContentDTO { + $lineupEntries = $this->lineupEntriesInEmailOrder($event); + $scheduleItems = $this->scheduleItemsInTimeOrder($event); + $ticketEmailLineup = $this->ticketEmailLineupContent($event, $lineupEntries, $scheduleItems); + return new EventSpamCheckContentDTO( title: $event->getTitle(), description: $event->getDescription(), supplementaryContent: array_filter([ + ...$this->withLabelPrefix(self::TICKET_EMAIL_LABEL_PREFIX, $ticketEmailLineup), + ...array_diff_key($this->lineupHeadlineContent($event, $lineupEntries, $scheduleItems), $ticketEmailLineup), ...$this->organizerContent($event), ...$this->settingsContent($event), ...$this->productContent($event), ...$this->categoryContent($event), ...$this->questionContent($event), - ...$this->lineupContent($event), - ...$this->scheduleContent($event), + ...$this->lineupContent($lineupEntries), + ...$this->scheduleContent($scheduleItems), ], static fn (?string $value): bool => $value !== null && trim($value) !== ''), ); } + /** + * @param Collection $lineupEntries + * @param Collection $scheduleItems + * @return array + */ + private function ticketEmailLineupContent(EventDomainObject $event, Collection $lineupEntries, Collection $scheduleItems): array + { + $selection = $this->lineupHighlightSelector->select( + $lineupEntries->filter( + static fn (EventLineupEntryDomainObject $entry): bool => ! $entry->getIsHidden() && $entry->getProfile() !== null + ), + $scheduleItems, + ); + $spaces = collect($event->getEventSpaces() ?? [])->values(); + $slotContent = []; + + foreach ($selection->slots as $index => $slot) { + /** @var EventScheduleItemDomainObject $item */ + $item = $slot['item']; + + if ($item->getTitle() !== null) { + $slotContent['schedule item '.($index + 1).' title'] = $item->getTitle(); + } else { + $slotContent = [...$slotContent, ...$this->lineupNameContent($slot['people'])]; + } + + $spaceIndex = $item->getEventSpaceId() === null + ? false + : $spaces->search(static fn (EventSpaceDomainObject $space): bool => $space->getId() === $item->getEventSpaceId()); + + if ($spaceIndex !== false) { + $slotContent['space '.($spaceIndex + 1).' name'] = $spaces[$spaceIndex]->getName(); + } + } + + return [ + ...$this->lineupNameContent($selection->entries), + ...$slotContent, + ]; + } + + /** + * @param array $content + * @return array + */ + private function withLabelPrefix(string $prefix, array $content): array + { + return collect($content) + ->mapWithKeys(static fn (?string $value, string $label): array => [$prefix.$label => $value]) + ->all(); + } + + /** + * @param Collection $entries + * @return array + */ + private function lineupNameContent(Collection $entries): array + { + $content = []; + + foreach ($entries as $index => $entry) { + $content['lineup '.($index + 1).' name'] = $entry->getProfile()?->getName(); + } + + return $content; + } + + /** + * @param Collection $lineupEntries + * @param Collection $scheduleItems + * @return array + */ + private function lineupHeadlineContent(EventDomainObject $event, Collection $lineupEntries, Collection $scheduleItems): array + { + $content = $this->lineupNameContent($lineupEntries->reject( + static fn (EventLineupEntryDomainObject $entry): bool => $entry->getIsHidden() + )); + + foreach ($scheduleItems as $index => $item) { + $content['schedule item '.($index + 1).' title'] = $item->getTitle(); + } + + foreach ($event->getEventSpaces() ?? [] as $index => $space) { + /** @var EventSpaceDomainObject $space */ + $content['space '.($index + 1).' name'] = $space->getName(); + } + + return $content; + } + /** * @return array */ @@ -187,23 +298,27 @@ private function questionContent(EventDomainObject $event): array } /** + * @param Collection $lineupEntries * @return array */ - private function lineupContent(EventDomainObject $event): array + private function lineupContent(Collection $lineupEntries): array { $content = []; - foreach ($event->getEventLineupEntries() ?? [] as $index => $entry) { - /** @var EventLineupEntryDomainObject $entry */ + foreach ($lineupEntries as $index => $entry) { $profile = $entry->getProfile(); $prefix = 'lineup '.($index + 1); + + if ($entry->getIsHidden()) { + $content[$prefix.' name'] = $profile?->getName(); + } + $content[$prefix.' role'] = $entry->getRoleLabel(); if ($profile === null) { continue; } - $content[$prefix.' name'] = $profile->getName(); $content[$prefix.' tagline'] = $profile->getTagline(); $content[$prefix.' bio'] = $profile->getBio(); $content[$prefix.' links'] = implode(' ', array_column($profile->getLinks() ?? [], 'url')); @@ -213,23 +328,44 @@ private function lineupContent(EventDomainObject $event): array } /** + * @param Collection $scheduleItems * @return array */ - private function scheduleContent(EventDomainObject $event): array + private function scheduleContent(Collection $scheduleItems): array { $content = []; - foreach ($event->getEventSpaces() ?? [] as $index => $space) { - /** @var EventSpaceDomainObject $space */ - $content['space '.($index + 1).' name'] = $space->getName(); - } - - foreach ($event->getEventScheduleItems() ?? [] as $index => $item) { - /** @var EventScheduleItemDomainObject $item */ - $content['schedule item '.($index + 1).' title'] = $item->getTitle(); + foreach ($scheduleItems as $index => $item) { $content['schedule item '.($index + 1).' description'] = $item->getDescription(); } return $content; } + + /** + * @return Collection + */ + private function lineupEntriesInEmailOrder(EventDomainObject $event): Collection + { + $orderKey = static fn (EventLineupEntryDomainObject $entry): array => [ + (bool) $entry->getIsHidden(), + self::LINEUP_TIER_RANKS[$entry->getBillingTier()] ?? count(self::LINEUP_TIER_RANKS), + $entry->getSortOrder(), + $entry->getId(), + ]; + + return collect($event->getEventLineupEntries() ?? []) + ->sort(static fn (EventLineupEntryDomainObject $a, EventLineupEntryDomainObject $b): int => $orderKey($a) <=> $orderKey($b)) + ->values(); + } + + /** + * @return Collection + */ + private function scheduleItemsInTimeOrder(EventDomainObject $event): Collection + { + return collect($event->getEventScheduleItems() ?? []) + ->sort(static fn (EventScheduleItemDomainObject $a, EventScheduleItemDomainObject $b): int => [$a->getStartsAt(), $a->getId()] <=> [$b->getStartsAt(), $b->getId()]) + ->values(); + } } diff --git a/backend/app/Services/Domain/Event/EventSpamCheckService.php b/backend/app/Services/Domain/Event/EventSpamCheckService.php index 24624a58fb..6e0e3c69ce 100644 --- a/backend/app/Services/Domain/Event/EventSpamCheckService.php +++ b/backend/app/Services/Domain/Event/EventSpamCheckService.php @@ -23,6 +23,14 @@ class EventSpamCheckService private const MAX_LINKS = 30; + private const EMAIL_FIRST_LABEL_PATTERNS = [ + '/^organizer name$/', + '/^'.EventSpamCheckContentService::TICKET_EMAIL_LABEL_PREFIX.'/', + '/^product \d+ title$/', + '/^category \d+ name$/', + '/^(offline payment instructions|post-checkout message|online event connection details|email footer message)$/', + ]; + public function __construct( private readonly Repository $config, ) {} @@ -64,6 +72,7 @@ public function hashContent(EventSpamCheckContentDTO $content): string private function buildPrompt(EventSpamCheckContentDTO $content): string { + $content = $this->emailFirst($content); $title = $this->toPlainText($content->title, self::TITLE_MAX_LENGTH); $description = $this->toPlainText($content->description, self::DESCRIPTION_MAX_LENGTH); $supplementary = $this->buildSupplementary($content); @@ -83,6 +92,34 @@ private function buildPrompt(EventSpamCheckContentDTO $content): string PROMPT; } + private function emailFirst(EventSpamCheckContentDTO $content): EventSpamCheckContentDTO + { + $tiers = []; + + foreach ($content->supplementaryContent as $label => $value) { + $tiers[$this->emailFirstTier((string) $label)][$label] = $value; + } + + ksort($tiers); + + return new EventSpamCheckContentDTO( + title: $content->title, + description: $content->description, + supplementaryContent: array_merge([], ...$tiers), + ); + } + + private function emailFirstTier(string $label): int + { + foreach (self::EMAIL_FIRST_LABEL_PATTERNS as $tier => $pattern) { + if (preg_match($pattern, $label) === 1) { + return $tier; + } + } + + return count(self::EMAIL_FIRST_LABEL_PATTERNS); + } + private function buildSupplementary(EventSpamCheckContentDTO $content): string { $lines = []; diff --git a/backend/app/Services/Domain/Lineup/DTO/LineupHighlightSelectionDTO.php b/backend/app/Services/Domain/Lineup/DTO/LineupHighlightSelectionDTO.php new file mode 100644 index 0000000000..3c26b99917 --- /dev/null +++ b/backend/app/Services/Domain/Lineup/DTO/LineupHighlightSelectionDTO.php @@ -0,0 +1,22 @@ + $entries + * @param Collection}> $slots + */ + public function __construct( + public readonly Collection $entries, + public readonly Collection $slots, + ) {} +} diff --git a/backend/app/Services/Domain/Lineup/EmailLineupHighlightsService.php b/backend/app/Services/Domain/Lineup/EmailLineupHighlightsService.php index bb619a0f35..7e2b1e7716 100644 --- a/backend/app/Services/Domain/Lineup/EmailLineupHighlightsService.php +++ b/backend/app/Services/Domain/Lineup/EmailLineupHighlightsService.php @@ -6,7 +6,6 @@ use Carbon\CarbonImmutable; use HiEvents\DomainObjects\Enums\HomepageSectionType; -use HiEvents\DomainObjects\Enums\LineupBillingTier; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventLineupEntryDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; @@ -28,10 +27,6 @@ class EmailLineupHighlightsService { - private const MAX_NAMES = 6; - - private const MAX_SLOTS = 5; - private const SPEAKER_CATEGORIES = ['BUSINESS', 'TECH', 'EDUCATION']; private const HOST_CATEGORIES = ['WORKSHOP', 'WELLNESS', 'SPIRITUALITY', 'SPORTS', 'HOBBIES']; @@ -40,6 +35,7 @@ public function __construct( private readonly EventLineupEntryRepositoryInterface $lineupEntryRepository, private readonly EventScheduleItemRepositoryInterface $scheduleItemRepository, private readonly EventSpaceRepositoryInterface $spaceRepository, + private readonly LineupHighlightSelector $highlightSelector, ) {} public function toTemplateContext(?EmailLineupHighlightsDTO $highlights): array @@ -79,19 +75,10 @@ public function build( $allItems = $this->scheduleItems($event->getId()); $items = $this->itemsForOccurrence($allItems, $occurrence, $timezone); $entries = $occurrence === null ? $entries : $this->entriesForOccurrence($entries, $items, $allItems); - $highlighted = $this->highlightedEntries($entries); - $highlightedIds = $highlighted->map(fn (EventLineupEntryDomainObject $entry) => $entry->getId())->all(); - - $slotRows = $showSchedule ? $this->slotRows($items, $entries, $highlightedIds) : collect(); - $namedInSlots = $slotRows - ->filter(fn (array $row) => $row['item']->getTitle() === null) - ->flatMap(fn (array $row) => $row['people']->map(fn (EventLineupEntryDomainObject $entry) => $entry->getId())) - ->all(); - $slots = $this->formatSlots($event->getId(), $slotRows, $timezone); + $selection = $this->highlightSelector->select($entries, $showSchedule ? $items : collect()); + $slots = $this->formatSlots($event->getId(), $selection->slots, $timezone); $names = $showLineup - ? $highlighted - ->reject(fn (EventLineupEntryDomainObject $entry) => in_array($entry->getId(), $namedInSlots, true)) - ->take(self::MAX_NAMES) + ? $selection->entries ->map(fn (EventLineupEntryDomainObject $entry) => $entry->getProfile()->getName()) ->values() ->all() @@ -169,43 +156,6 @@ private function entriesForOccurrence(Collection $entries, Collection $items, Co ->values(); } - private function highlightedEntries(Collection $entries): Collection - { - foreach ([LineupBillingTier::HEADLINER, LineupBillingTier::FEATURED] as $tier) { - $matches = $entries->filter(fn (EventLineupEntryDomainObject $entry) => $entry->getBillingTier() === $tier->value); - - if ($matches->isNotEmpty()) { - return $matches->values(); - } - } - - return $entries; - } - - /** - * @param int[] $highlightedIds - */ - private function slotRows(Collection $items, Collection $entries, array $highlightedIds): Collection - { - $entriesById = $entries->keyBy(fn (EventLineupEntryDomainObject $entry) => $entry->getId()); - - $withPeople = $items - ->map(fn (EventScheduleItemDomainObject $item) => [ - 'item' => $item, - 'people' => ($item->getPerformers() ?? collect()) - ->map(fn (EventScheduleItemPerformerDomainObject $performer) => $entriesById->get($performer->getEventLineupEntryId())) - ->filter() - ->values(), - ]) - ->filter(fn (array $row) => $row['item']->getTitle() !== null || $row['people']->isNotEmpty()); - - $featured = $withPeople->filter(fn (array $row) => $row['people']->contains( - fn (EventLineupEntryDomainObject $entry) => in_array($entry->getId(), $highlightedIds, true) - )); - - return ($featured->isNotEmpty() ? $featured : $withPeople)->take(self::MAX_SLOTS)->values(); - } - /** * @return array */ @@ -229,6 +179,7 @@ private function formatSlots(int $eventId, Collection $slotRows, string $timezon ->implode(', '), 'stage' => $spacesById->get($row['item']->getEventSpaceId() ?? '')?->getName(), ]) + ->values() ->all(); } diff --git a/backend/app/Services/Domain/Lineup/LineupHighlightSelector.php b/backend/app/Services/Domain/Lineup/LineupHighlightSelector.php new file mode 100644 index 0000000000..be1b17a439 --- /dev/null +++ b/backend/app/Services/Domain/Lineup/LineupHighlightSelector.php @@ -0,0 +1,69 @@ + $entries + * @param Collection $items + */ + public function select(Collection $entries, Collection $items): LineupHighlightSelectionDTO + { + $highlighted = $this->highlightedEntries($entries); + $slots = $this->slotRows($items, $entries, $highlighted); + $namedInSlots = $slots + ->filter(fn (array $slot) => $slot['item']->getTitle() === null) + ->reduce(fn (Collection $named, array $slot) => $named->union($slot['people']), collect()); + + return new LineupHighlightSelectionDTO( + entries: $highlighted->diffKeys($namedInSlots)->take(self::MAX_NAMES), + slots: $slots, + ); + } + + private function highlightedEntries(Collection $entries): Collection + { + foreach ([LineupBillingTier::HEADLINER, LineupBillingTier::FEATURED] as $tier) { + $matches = $entries->filter(fn (EventLineupEntryDomainObject $entry) => $entry->getBillingTier() === $tier->value); + + if ($matches->isNotEmpty()) { + return $matches; + } + } + + return $entries; + } + + private function slotRows(Collection $items, Collection $entries, Collection $highlighted): Collection + { + $entryKeysById = $entries->map(fn (EventLineupEntryDomainObject $entry) => $entry->getId())->flip(); + + $rows = $items + ->map(fn (EventScheduleItemDomainObject $item) => [ + 'item' => $item, + 'people' => collect($item->getPerformers() ?? []) + ->map(fn (EventScheduleItemPerformerDomainObject $performer) => $entryKeysById->get($performer->getEventLineupEntryId())) + ->reject(fn (?int $entryKey) => $entryKey === null) + ->mapWithKeys(fn (int $entryKey) => [$entryKey => $entries->get($entryKey)]), + ]) + ->filter(fn (array $row) => $row['item']->getTitle() !== null || $row['people']->isNotEmpty()); + + $featured = $rows->filter(fn (array $row) => $row['people']->intersectByKeys($highlighted)->isNotEmpty()); + + return ($featured->isNotEmpty() ? $featured : $rows)->take(self::MAX_SLOTS); + } +} diff --git a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php index 2652de1ae1..6a1e52552d 100644 --- a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php +++ b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php @@ -64,6 +64,13 @@ public function send(SendMessageDTO $messageData): void throw new UnableToSendMessageException($message); } + if ($messageData->is_test) { + $this->sendEmailToMessageSender($messageData, $event); + $this->updateMessageStatus($messageData, MessageStatus::SENT); + + return; + } + switch ($messageData->type) { case MessageTypeEnum::INDIVIDUAL_ATTENDEES: $this->sendAttendeeMessages($messageData, $event); @@ -144,10 +151,6 @@ private function emailAttendees( ): void { $this->sendEmailToMessageSender($messageData, $event); - if ($messageData->is_test) { - return; - } - $sentEmails = []; $attendees->each(function (AttendeeDomainObject $attendee) use (&$sentEmails, $event, $messageData) { if ($attendee->getEmail() === null || in_array($attendee->getEmail(), $sentEmails, true)) { diff --git a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php index 75e2fe2941..53a5e5fd5e 100644 --- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php +++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php @@ -171,9 +171,10 @@ private function sendOrderSummaryEmails(OrderDomainObject $order, EventDomainObj excludeEmail: $notifiesOrganizer ? $event->getOrganizer()->getEmail() : null, ); - foreach ($teamRecipients as $email) { + foreach ($teamRecipients as $user) { $this->mailer - ->to($email) + ->to($user->getEmail()) + ->locale($user->getLocale()) ->send(new OrderSummaryForOrganizer($order, $event)); } } diff --git a/backend/app/Services/Domain/Mail/TeamOrderNotificationRecipientService.php b/backend/app/Services/Domain/Mail/TeamOrderNotificationRecipientService.php index 2f95fd6d22..b123a0f23c 100644 --- a/backend/app/Services/Domain/Mail/TeamOrderNotificationRecipientService.php +++ b/backend/app/Services/Domain/Mail/TeamOrderNotificationRecipientService.php @@ -20,7 +20,7 @@ public function __construct( ) {} /** - * @return string[] + * @return UserDomainObject[] */ public function recipientsFor(EventDomainObject $event, ?string $excludeEmail): array { @@ -52,9 +52,8 @@ public function recipientsFor(EventDomainObject $event, ?string $excludeEmail): values: $subscribers->map(fn (AccountUserDomainObject $accountUser) => $accountUser->getUserId())->all(), additionalWhere: [['email_verified_at', 'not null', null]], ) - ->map(fn (UserDomainObject $user) => strtolower($user->getEmail())) - ->reject(fn (string $email) => $email === $excluded) - ->unique() + ->reject(fn (UserDomainObject $user) => strtolower($user->getEmail()) === $excluded) + ->unique(fn (UserDomainObject $user) => strtolower($user->getEmail())) ->values() ->all(); } diff --git a/backend/app/Services/Domain/Message/MessageDispatchService.php b/backend/app/Services/Domain/Message/MessageDispatchService.php index 409c36feac..2ee72e44a4 100644 --- a/backend/app/Services/Domain/Message/MessageDispatchService.php +++ b/backend/app/Services/Domain/Message/MessageDispatchService.php @@ -69,7 +69,7 @@ public function dispatchMessage(MessageDomainObject $message, MessageStatus $exp subject: $message->getSubject(), message: $message->getMessage(), type: MessageTypeEnum::fromName($message->getType()), - is_test: false, + is_test: (bool) ($sendDataArray['is_test'] ?? false), send_copy_to_current_user: $sendDataArray['send_copy_to_current_user'] ?? false, sent_by_user_id: $message->getSentByUserId(), order_id: $message->getOrderId(), diff --git a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php index a3aa8f69d1..551c711f26 100644 --- a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php +++ b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php @@ -58,7 +58,6 @@ public function markOrderAsPaid( $order = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) ->loadRelation(AttendeeDomainObject::class) - ->loadRelation(InvoiceDomainObject::class) ->findFirstWhere([ OrderDomainObjectAbstract::ID => $orderId, OrderDomainObjectAbstract::EVENT_ID => $eventId, @@ -103,6 +102,7 @@ public function markOrderAsPaid( ], )) ->loadRelation(AttendeeDomainObject::class) + ->loadRelation(InvoiceDomainObject::class) ->findById($orderId); if ($updatedOrder->getAffiliateId()) { @@ -133,7 +133,7 @@ public function markOrderAsPaid( event: $event, organizer: $event->getOrganizer(), eventSettings: $event->getEventSettings(), - invoice: $order->getLatestInvoice(), + invoice: $updatedOrder->getLatestInvoice(), ); return $updatedOrder; diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php index 6212d7f075..4bcdce8317 100644 --- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php +++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php @@ -4,17 +4,25 @@ use Brick\Money\Money; use HiEvents\DomainObjects\Enums\PaymentProviders; +use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; +use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\Status\OrderRefundStatus; +use HiEvents\Mail\Order\OrderRefunded; +use HiEvents\Repository\Eloquent\Value\Relationship; +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\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 Stripe\Refund; @@ -30,6 +38,9 @@ public function __construct( private readonly EventStatisticsRefundService $eventStatisticsRefundService, private readonly OrderRefundRepositoryInterface $orderRefundRepository, private readonly DomainEventDispatcherService $domainEventDispatcherService, + private readonly TransactionLockService $transactionLockService, + private readonly EventRepositoryInterface $eventRepository, + private readonly Mailer $mailer, ) {} /** @@ -46,6 +57,8 @@ public function handleEvent(Refund $refund): void return; } + $this->lockOrder($stripePayment->getOrderId()); + $existingRefund = $this->orderRefundRepository->findFirstWhere([ 'refund_id' => $refund->id, ]); @@ -60,21 +73,37 @@ public function handleEvent(Refund $refund): void return; } + if ($this->isStillProcessing($refund)) { + $this->logger->info('Stripe refund is still processing', [ + 'refund_id' => $refund->id, + 'payment_intent_id' => $refund->payment_intent, + 'status' => $refund->status, + 'pending_reason' => $refund->pending_reason ?? null, + ]); + + return; + } + $order = $this->orderRepository->findById($stripePayment->getOrderId()); - if ($refund->status !== 'succeeded') { + if ($refund->status !== Refund::STATUS_SUCCEEDED) { $this->handleFailure($refund, $order); return; } $refundedAmount = $this->amountAsFloat($refund->amount, $order->getCurrency()); + $refundedMoney = MoneyValue::fromMinorUnit($refund->amount, $order->getCurrency()); $this->updateOrderRefundedAmount($order->getId(), $refundedAmount); $this->updateOrderStatus($order, $refundedAmount); - $this->updateEventStatistics($order, MoneyValue::fromMinorUnit($refund->amount, $order->getCurrency())); + $this->updateEventStatistics($order, $refundedMoney); $this->createOrderRefund($refund, $order, $refundedAmount); + if ($this->wasBuyerNotificationRequested($refund) && $order->getEmail() !== null) { + $this->notifyBuyer($order, $refundedMoney); + } + $this->logger->info(__('Stripe refund successful'), [ 'order_id' => $order->getId(), 'refunded_amount' => $refundedAmount, @@ -91,6 +120,40 @@ public function handleEvent(Refund $refund): void }); } + private function lockOrder(int $orderId): void + { + $this->transactionLockService->lockOrder($this->orderRepository->findById($orderId)->getShortId()); + } + + private function isStillProcessing(Refund $refund): bool + { + return in_array($refund->status, [Refund::STATUS_PENDING, Refund::STATUS_REQUIRES_ACTION], true); + } + + private function wasBuyerNotificationRequested(Refund $refund): bool + { + return ($refund->metadata[StripePaymentIntentRefundService::NOTIFY_BUYER_METADATA_KEY] ?? null) === '1'; + } + + private function notifyBuyer(OrderDomainObject $order, MoneyValue $refundAmount): void + { + $event = $this->eventRepository + ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(EventSettingDomainObject::class) + ->findById($order->getEventId()); + + $this->mailer + ->to($order->getEmail()) + ->locale($order->getLocale()) + ->send(new OrderRefunded( + order: $order, + event: $event, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + refundAmount: $refundAmount, + )); + } + private function amountAsFloat(int $amount, string $currency): float { return Money::ofMinor($amount, $currency)->getAmount()->toFloat(); @@ -127,16 +190,18 @@ private function handleFailure(Refund $refund, OrderDomainObject $order): void OrderDomainObjectAbstract::REFUND_STATUS => OrderRefundStatus::REFUND_FAILED->name, ]); + $this->createOrderRefund($refund, $order, $this->amountAsFloat($refund->amount, $order->getCurrency())); + $this->logger->error(__('Failed to refund stripe charge'), $refund->toArray()); } - private function createOrderRefund(Refund $refund, OrderDomainObject $order, float $refundedAmount): void + private function createOrderRefund(Refund $refund, OrderDomainObject $order, float $amount): void { $this->orderRefundRepository->create([ 'order_id' => $order->getId(), 'payment_provider' => PaymentProviders::STRIPE->value, 'refund_id' => $refund->id, - 'amount' => $refundedAmount, + 'amount' => $amount, 'currency' => $order->getCurrency(), 'status' => $refund->status, 'metadata' => array_merge($refund->metadata?->toArray() ?? [], [ diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php index c31405a1ea..33fd2cee0a 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php @@ -13,6 +13,8 @@ class StripePaymentIntentRefundService { + public const NOTIFY_BUYER_METADATA_KEY = 'notify_buyer'; + public function __construct( private readonly Repository $config, ) {} @@ -20,34 +22,48 @@ public function __construct( /** * @throws ApiErrorException * @throws MathException - * - * @todo - catch and handle stripe errors */ public function refundPayment( MoneyValue $amount, + MoneyValue $alreadyRefunded, + int $failedRefundCount, StripePaymentDomainObject $payment, StripeClient $stripeClient, + bool $notifyBuyer, ): Refund { - $opts = array_merge( - $this->getStripeAccountData($payment), - [ - 'idempotency_key' => sprintf( - 'refund_%s_amount_%d', - $payment->getPaymentIntentId(), - $amount->toMinorUnit(), - ), - ], - ); + $params = [ + 'payment_intent' => $payment->getPaymentIntentId(), + 'amount' => $amount->toMinorUnit(), + ]; + + if ($notifyBuyer) { + $params['metadata'] = [self::NOTIFY_BUYER_METADATA_KEY => '1']; + } return $stripeClient->refunds->create( - params: [ - 'payment_intent' => $payment->getPaymentIntentId(), - 'amount' => $amount->toMinorUnit(), - ], - opts: $opts, + params: $params, + opts: array_merge( + $this->getStripeAccountData($payment), + ['idempotency_key' => $this->idempotencyKey($payment, $amount, $alreadyRefunded, $failedRefundCount)], + ), ); } + private function idempotencyKey( + StripePaymentDomainObject $payment, + MoneyValue $amount, + MoneyValue $alreadyRefunded, + int $failedRefundCount, + ): string { + $key = sprintf('refund_%s_amount_%d', $payment->getPaymentIntentId(), $amount->toMinorUnit()); + + if ($alreadyRefunded->toMinorUnit() === 0 && $failedRefundCount === 0) { + return $key; + } + + return sprintf('%s_after_%d_failed_%d', $key, $alreadyRefunded->toMinorUnit(), $failedRefundCount); + } + private function getStripeAccountData(StripePaymentDomainObject $payment): array { if ($this->config->get('app.saas_mode_enabled')) { diff --git a/backend/app/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderService.php b/backend/app/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderService.php index bb196b1dfa..7d01159bd5 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderService.php @@ -58,9 +58,17 @@ public function refundExpiredOrder( ->findById($order->getEventId()); $stripeClient = $this->stripeClientFactory->createForPlatform($stripePayment->getStripePlatformEnum()); - $amount = MoneyValue::fromMinorUnit($paymentIntent->amount, strtoupper($paymentIntent->currency)); + $currency = strtoupper($paymentIntent->currency); + $amount = MoneyValue::fromMinorUnit($paymentIntent->amount, $currency); - $refund = $this->refundService->refundPayment($amount, $stripePayment, $stripeClient); + $refund = $this->refundService->refundPayment( + amount: $amount, + alreadyRefunded: MoneyValue::zero($currency), + failedRefundCount: 0, + payment: $stripePayment, + stripeClient: $stripeClient, + notifyBuyer: false, + ); $this->logger->info('Refunded payment for an order that could not be completed', [ 'order_id' => $order->getId(), diff --git a/backend/app/Services/Domain/Product/ProductFilterService.php b/backend/app/Services/Domain/Product/ProductFilterService.php index 1137c9c3da..8d18a045c9 100644 --- a/backend/app/Services/Domain/Product/ProductFilterService.php +++ b/backend/app/Services/Domain/Product/ProductFilterService.php @@ -195,8 +195,17 @@ private function processProduct( if ($this->shouldProductBeDiscounted($promoCode, $product)) { $product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($product, $promoCode, $eventOccurrenceId) { - $price->setPriceBeforeDiscount($price->getPrice()); - $price->setPrice($this->productPriceService->getIndividualPrice($product, $price, $promoCode, $eventOccurrenceId)); + $discountedPrice = $this->productPriceService->getPrice( + $product, + new OrderProductPriceDTO(quantity: 1, price_id: $price->getId()), + $promoCode, + $eventOccurrenceId, + ); + $price->setPriceBeforeDiscount($discountedPrice->price_before_discount); + $price->setPriceBeforeDiscountIncludingTaxesAndFees($discountedPrice->price_before_discount === null + ? null + : $this->priceIncludingTaxesAndFees($product, $discountedPrice->price_before_discount)); + $price->setPrice($discountedPrice->price); }); } diff --git a/backend/app/Services/Infrastructure/Lock/TransactionLockService.php b/backend/app/Services/Infrastructure/Lock/TransactionLockService.php index 7de4c060b9..a74ccee264 100644 --- a/backend/app/Services/Infrastructure/Lock/TransactionLockService.php +++ b/backend/app/Services/Infrastructure/Lock/TransactionLockService.php @@ -12,6 +12,8 @@ class TransactionLockService public const ORDER_LOCK_KEYSPACE = 0x4F5244; + public const LINEUP_LOCK_KEYSPACE = 0x4C494E; + public const MAX_LOCK_KEY = 2147483647; public function __construct( @@ -26,6 +28,14 @@ public function lockEvent(int $eventId): void ); } + public function lockEventLineup(int $eventId): void + { + $this->databaseManager->statement( + 'SELECT pg_advisory_xact_lock(?, ?)', + [self::LINEUP_LOCK_KEYSPACE, $eventId % self::MAX_LOCK_KEY], + ); + } + public function lockOrder(string $orderShortId): void { $this->databaseManager->statement( diff --git a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php index 3d92b4c415..25ca8bdb61 100644 --- a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php +++ b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php @@ -27,12 +27,15 @@ use HiEvents\Resources\Product\ProductResource; use HiEvents\Services\Domain\Auth\Access\AccountUserAccessService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; +use Illuminate\Config\Repository; use Illuminate\Http\Resources\Json\JsonResource; use Psr\Log\LoggerInterface; use Spatie\WebhookServer\WebhookCall; class WebhookDispatchService { + private const LARGEST_QUEUED_PAYLOAD_BYTES = 120 * 1024; + public function __construct( private readonly LoggerInterface $logger, private readonly WebhookRepositoryInterface $webhookRepository, @@ -43,6 +46,7 @@ public function __construct( private readonly EventRepositoryInterface $eventRepository, private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository, private readonly AccountUserAccessService $accessService, + private readonly Repository $config, ) {} public function dispatchEventWebhook(DomainEventType $eventType, int $eventId): void @@ -197,12 +201,16 @@ private function dispatchWebhook(DomainEventType $eventType, JsonResource $paylo return; } - $resolvedPayload = $this->accessService->withoutMemberRestrictions(fn () => $payload->resolve()); + $encodedPayload = $this->accessService->withoutMemberRestrictions( + fn () => json_encode($payload, JSON_THROW_ON_ERROR), + ); + $resolvedPayload = json_decode($encodedPayload, flags: JSON_THROW_ON_ERROR); + $fitsOnTheQueue = strlen($encodedPayload) <= self::LARGEST_QUEUED_PAYLOAD_BYTES; foreach ($webhooks as $webhook) { $this->logger->info("Dispatching webhook for event ID: $eventId and webhook ID: {$webhook->getId()}"); - WebhookCall::create() + $call = WebhookCall::create() ->url($webhook->getUrl()) ->payload([ 'event_type' => $eventType->value, @@ -214,8 +222,15 @@ private function dispatchWebhook(DomainEventType $eventType, JsonResource $paylo 'webhook_id' => $webhook->getId(), 'event_id' => $eventId, 'event_type' => $eventType->name, - ]) - ->dispatchSync(); + ]); + + if (! $fitsOnTheQueue) { + $call->dispatchSync(); + + continue; + } + + $call->onQueue($this->config->get('queue.webhook_queue_name'))->dispatch(); } } } diff --git a/backend/app/Validators/Rules/HomepageSectionRules.php b/backend/app/Validators/Rules/HomepageSectionRules.php index 513965b92b..43322fc1d9 100644 --- a/backend/app/Validators/Rules/HomepageSectionRules.php +++ b/backend/app/Validators/Rules/HomepageSectionRules.php @@ -35,9 +35,40 @@ public static function rules(array $allowedTypes): array public static function messages(): array { + $invalidSection = __("Something in your homepage sections couldn't be saved. Reload the page and try again."); + return [ + 'homepage_sections' => $invalidSection, + 'homepage_sections.max' => __('You can add up to :max sections.'), + 'homepage_sections.*.id' => $invalidSection, + 'homepage_sections.*.type' => $invalidSection, + 'homepage_sections.*.enabled' => $invalidSection, + 'homepage_sections.*.heading' => $invalidSection, + 'homepage_sections.*.heading.max' => __('Section headings can be up to :max characters.'), + 'homepage_sections.*.body' => $invalidSection, + 'homepage_sections.*.body.max' => __('Text sections can be up to :max characters.'), + 'homepage_sections.*.faqs' => $invalidSection, + 'homepage_sections.*.faqs.max' => __('An FAQ section can have up to :max questions.'), + 'homepage_sections.*.faqs.*.question' => $invalidSection, + 'homepage_sections.*.faqs.*.question.required' => __('Every FAQ needs a question.'), + 'homepage_sections.*.faqs.*.question.max' => __('FAQ questions can be up to :max characters.'), + 'homepage_sections.*.faqs.*.answer' => $invalidSection, + 'homepage_sections.*.faqs.*.answer.max' => __('FAQ answers can be up to :max characters.'), + 'homepage_sections.*.video_url' => $invalidSection, 'homepage_sections.*.video_url.url' => __('Use a YouTube or Vimeo link starting with https://'), + 'homepage_sections.*.video_url.max' => __('Video links can be up to :max characters.'), + 'homepage_sections.*.image_ids' => $invalidSection, + 'homepage_sections.*.image_ids.max' => __('A gallery can have up to :max photos.'), + 'homepage_sections.*.image_ids.*.integer' => $invalidSection, + 'homepage_sections.*.sponsors' => $invalidSection, + 'homepage_sections.*.sponsors.max' => __('You can add up to :max sponsors.'), + 'homepage_sections.*.sponsors.*.image_id' => $invalidSection, + 'homepage_sections.*.sponsors.*.image_id.required' => __('Every sponsor needs a logo.'), + 'homepage_sections.*.sponsors.*.name' => $invalidSection, + 'homepage_sections.*.sponsors.*.name.max' => __('Sponsor names can be up to :max characters.'), + 'homepage_sections.*.sponsors.*.url' => $invalidSection, 'homepage_sections.*.sponsors.*.url.url' => __('Sponsor links must start with https://'), + 'homepage_sections.*.sponsors.*.url.max' => __('Sponsor links can be up to :max characters.'), ]; } } diff --git a/backend/composer.json b/backend/composer.json index 624d6ddf41..5e37eb2e1f 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -4,7 +4,7 @@ "description": "hi.events - Ticket selling and event management.", "keywords": ["ticketing", "events"], "license": "AGPL-3.0", - "version": "2.0.0-beta", + "version": "2.0.0-rc.3", "require": { "php": "^8.3", "ext-intl": "*", diff --git a/backend/config/app.php b/backend/config/app.php index bcb683497e..fd23c17f10 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -16,7 +16,7 @@ 'reset_password_token_expiry_in_min' => 15, 'frontend_url' => env('APP_FRONTEND_URL', 'http://localhost'), 'api_url' => env('APP_URL', 'https://localhost:8443'), - 'cnd_url' => env('APP_CDN_URL', '/storage'), + 'cdn_url' => env('APP_CDN_URL'), 'default_timezone' => 'America/Vancouver', 'default_currency_code' => 'USD', 'saas_mode_enabled' => env('APP_SAAS_MODE_ENABLED', false), diff --git a/backend/config/queue.php b/backend/config/queue.php index fd24f7d4fc..cdf8db9e34 100644 --- a/backend/config/queue.php +++ b/backend/config/queue.php @@ -5,32 +5,8 @@ 'occurrences_queue_name' => env('OCCURRENCES_QUEUE_NAME'), - /* - |-------------------------------------------------------------------------- - | Default Queue Connection Name - |-------------------------------------------------------------------------- - | - | Laravel's queue API supports an assortment of back-ends via a single - | API, giving you convenient access to each back-end using the same - | syntax for every one. Here you may define a default connection. - | - */ - 'default' => env('QUEUE_CONNECTION', 'sync'), - /* - |-------------------------------------------------------------------------- - | Queue Connections - |-------------------------------------------------------------------------- - | - | Here you may configure the connection information for each server that - | is used by your application. A default configuration has been added - | for each back-end shipped with Laravel. You are free to add more. - | - | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" - | - */ - 'connections' => [ 'sync' => [ @@ -76,36 +52,14 @@ ], - /* - |-------------------------------------------------------------------------- - | Job Batching - |-------------------------------------------------------------------------- - | - | The following options configure the database and table that store job - | batching information. These options can be updated to any database - | connection and table which has been defined by your application. - | - */ - 'batching' => [ - 'database' => env('DB_CONNECTION', 'mysql'), + 'database' => env('DB_CONNECTION', 'pgsql'), 'table' => 'job_batches', ], - /* - |-------------------------------------------------------------------------- - | Failed Queue Jobs - |-------------------------------------------------------------------------- - | - | These options configure the behavior of failed queue job logging so you - | can control which database and table are used to store the jobs that - | have failed. You may change them to any database / table you wish. - | - */ - 'failed' => [ 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), - 'database' => env('DB_CONNECTION', 'mysql'), + 'database' => env('DB_CONNECTION', 'pgsql'), 'table' => 'failed_jobs', ], ]; diff --git a/backend/config/sso.php b/backend/config/sso.php index 8233cc7299..a1d6a8e1c4 100644 --- a/backend/config/sso.php +++ b/backend/config/sso.php @@ -3,8 +3,6 @@ return [ 'password_login_disabled' => (bool) env('SSO_PASSWORD_LOGIN_DISABLED', false), - 'buttons_visible' => (bool) env('SSO_BUTTONS_VISIBLE', true), - 'allow_private_issuers' => (bool) env('SSO_ALLOW_PRIVATE_ISSUERS', false), 'providers' => [ diff --git a/backend/ee/LICENCE b/backend/ee/LICENCE index 8b8a228556..cf01d3331e 100644 --- a/backend/ee/LICENCE +++ b/backend/ee/LICENCE @@ -8,13 +8,18 @@ of Hi.Events. They are covered by the terms below. TL;DR - Self-hosting Hi.Events is free, and these files can stay in your install. -- Using the features they add, like reserved seating and the box office, for - real events needs a licence: https://hi.events/licensing +- Using the features they add, like reserved seating, the box office and + enterprise single sign-on, for real events needs a licence: + https://hi.events/licensing - Development and testing are always free. - You can share, fork and modify this code as part of Hi.Events, but don't remove or bypass the licence key checks. -- If your licence ends, you can keep running event dates that were already - scheduled. You just can't set up anything new. +- If your licence ends, online ticket sales for events with reserved seating + keep working, and enterprise single sign-on connections keep signing people + in. You can switch those connections off or delete them, but not change + them or switch them back on until you renew. New box office sign-ins are + refused, so open box office sessions end within 24 hours. You can't set up + anything new. - If you have a signed licence agreement with us, that agreement comes first. This summary is for convenience. Where it differs from the terms below, the @@ -26,11 +31,11 @@ TERMS 1. Definitions. "We" and "us" mean Hi.Events Ltd. "The Software" means the files in this directory and its subdirectories, in source or compiled form, including modified versions of them. "Licensed Features" are the - Hi.Events features that require a licence key, including reserved seating - and the box office. A "Commercial Licence" is a licence to use Licensed - Features in production that we have issued to you, as a written agreement - or a licence key. "Production" means running real events, for example - selling or issuing tickets or admitting attendees. + Hi.Events features that require a licence key, including reserved seating, + the box office and enterprise single sign-on. A "Commercial Licence" is a + licence to use Licensed Features in production that we have issued to you, + as a written agreement or a licence key. "Production" means running real + events, for example selling or issuing tickets or admitting attendees. 2. Permission. Subject to these terms, you may use, copy, modify and distribute the Software, but only as part of Hi.Events or a modified @@ -60,10 +65,14 @@ TERMS have been removed, disabled or worked around, or remove any licensing or copyright notices. -7. When a Commercial Licence ends. You may keep using the Software to run - event dates that were already scheduled when your Commercial Licence - ended, and to wind down what you set up under it, but not to set up - anything new. +7. When a Commercial Licence ends. You may keep using the Software to sell + tickets online for events that used reserved seating when your + Commercial Licence ended, to keep using box office sessions that were + already open until they expire, to keep signing people in through + enterprise single sign-on connections that were already set up, and to + switch off, delete or otherwise wind down what you set up under it, but + not to set up anything new, start new box office sessions, or change or + switch back on anything you set up. 8. Commercial agreements. If you have a signed Commercial Licence agreement with us, its terms apply in place of these terms wherever the two differ. diff --git a/backend/ee/Sso/Services/Domain/AccountSsoConnectionService.php b/backend/ee/Sso/Services/Domain/AccountSsoConnectionService.php index 344886abb6..59c8dd3724 100644 --- a/backend/ee/Sso/Services/Domain/AccountSsoConnectionService.php +++ b/backend/ee/Sso/Services/Domain/AccountSsoConnectionService.php @@ -76,9 +76,13 @@ public function update(AccountSsoConnectionDomainObject $connection, AccountSsoC throw new AccountSsoConfigurationException(__('Stop requiring single sign-on before changing the identity provider details.')); } + if ($secret === null) { + throw new AccountSsoConfigurationException(__('Enter the client secret again when you change the identity provider details.'), 'client_secret'); + } + $attributes = [ ...$attributes, - ...$this->identityAttributes($data, $this->validateIdentityProvider($data, $secret ?? $connection->getClientSecret())), + ...$this->identityAttributes($data, $this->validateIdentityProvider($data, $secret)), 'status' => AccountSsoConnectionStatus::INACTIVE->value, 'last_tested_at' => null, ]; diff --git a/backend/ee/Sso/Services/Domain/DTO/LinkableSsoConnection.php b/backend/ee/Sso/Services/Domain/DTO/LinkableSsoConnection.php new file mode 100644 index 0000000000..08ad73a91b --- /dev/null +++ b/backend/ee/Sso/Services/Domain/DTO/LinkableSsoConnection.php @@ -0,0 +1,16 @@ +getAccountId(); $claimedEmailTrusted = $claims->emailTrusted && $this->domainService->isEmailOnUsableDomain($accountId, $claims->email); - $user = $this->findLinkedUser($claims) ?? $this->findOrCreateByEmail($connection, $claims, $claimedEmailTrusted); + $linkedUser = $this->findLinkedUser($claims); + + if ($linkedUser !== null && $this->connectionLinkService->isSuperAdmin($linkedUser->getId())) { + $this->logOutcome($connection, 'superadmin'); + + throw $this->signInWithPasswordInstead(); + } + + $user = $linkedUser ?? $this->findOrCreateByEmail($connection, $claims, $claimedEmailTrusted); $this->jitProvisioningService->ensureMembership( connection: $connection, @@ -72,10 +80,17 @@ private function findOrCreateByEmail(AccountSsoConnectionDomainObject $connectio if ($matches->count() > 1 || ($matches->count() === 1 && (! $emailTrusted || $this->isProtectedFromEmailLinking($matches->first())))) { $this->logOutcome($connection, $matches->count() > 1 ? 'ambiguous_email' : 'link_requires_login'); - throw new SsoException( - SsoErrorCode::LINK_REQUIRES_LOGIN, - __('Sign in with your email and password, then connect this sign-in option from your security settings.'), - ); + $canConnect = $matches->count() === 1 + && $emailTrusted + && $this->connectionLinkService->canLink($connection->getAccountId(), $matches->first()->getId()); + + throw $canConnect + ? new SsoException( + SsoErrorCode::LINK_REQUIRES_LOGIN, + __('Sign in with your email and password, then connect this sign-in option from your security settings.'), + ['email' => $claims->email], + ) + : $this->signInWithPasswordInstead(); } if ($matches->count() === 1) { @@ -104,15 +119,17 @@ public function hasMembershipsOutside(int $userId, int $accountId): bool private function isProtectedFromEmailLinking(UserDomainObject $user): bool { - return $this->accountUserRepository->findFirstWhere([ - 'user_id' => $user->getId(), - 'role' => Role::SUPERADMIN->name, - ]) !== null || $this->accountUserRepository->findFirstWhere([ + return $this->connectionLinkService->isSuperAdmin($user->getId()) || $this->accountUserRepository->findFirstWhere([ 'user_id' => $user->getId(), 'is_account_owner' => true, ]) !== null; } + private function signInWithPasswordInstead(): SsoException + { + return new SsoException(SsoErrorCode::LINK_REQUIRES_LOGIN, __('Sign in with your email and password instead.')); + } + private function logOutcome(AccountSsoConnectionDomainObject $connection, string $outcome): void { $this->logger->info('Enterprise SSO identity resolution', [ diff --git a/backend/ee/Sso/Services/Domain/SsoConnectionLinkService.php b/backend/ee/Sso/Services/Domain/SsoConnectionLinkService.php new file mode 100644 index 0000000000..e7e809359f --- /dev/null +++ b/backend/ee/Sso/Services/Domain/SsoConnectionLinkService.php @@ -0,0 +1,89 @@ +canLink((int) $provider->accountId, $userId)) { + throw new SsoException(SsoErrorCode::PROVIDER_NOT_CONFIGURED, __('This sign-in option is not available.')); + } + } + + public function canLink(int $accountId, int $userId): bool + { + return ! $this->isSuperAdmin($userId) && $this->accountUserRepository->findFirstWhere([ + 'user_id' => $userId, + 'account_id' => $accountId, + 'status' => UserStatus::ACTIVE->name, + ]) !== null; + } + + public function isSuperAdmin(int $userId): bool + { + return $this->accountUserRepository->findFirstWhere([ + 'user_id' => $userId, + 'role' => Role::SUPERADMIN->name, + ]) !== null; + } + + /** + * @return Collection + */ + public function linkableConnections(int $userId): Collection + { + if ($this->isSuperAdmin($userId)) { + return collect(); + } + + $accountNames = $this->accountUserRepository + ->loadRelation(new Relationship(AccountDomainObject::class, name: 'account')) + ->findWhere(['user_id' => $userId, 'status' => UserStatus::ACTIVE->name]) + ->mapWithKeys(fn (AccountUserDomainObject $accountUser) => [$accountUser->getAccountId() => (string) $accountUser->getAccount()?->getName()]); + + if ($accountNames->isEmpty()) { + return collect(); + } + + return $this->connectionRepository + ->findWhere( + where: [ + ['account_id', 'in', $accountNames->keys()->all()], + 'status' => AccountSsoConnectionStatus::ACTIVE->value, + ], + orderAndDirections: [new OrderAndDirection('id', 'asc')], + ) + ->map(fn (AccountSsoConnectionDomainObject $connection) => new LinkableSsoConnection( + key: $connection->getProviderKey(), + name: $connection->getConnectionType()->label(), + type: $connection->getConnectionType()->providerType(), + accountName: $accountNames->get($connection->getAccountId(), ''), + )) + ->values(); + } +} diff --git a/backend/ee/Sso/Services/Domain/SsoEnforcementService.php b/backend/ee/Sso/Services/Domain/SsoEnforcementService.php index 67f2efe9d4..e15d77555d 100644 --- a/backend/ee/Sso/Services/Domain/SsoEnforcementService.php +++ b/backend/ee/Sso/Services/Domain/SsoEnforcementService.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\AccountSsoConnectionDomainObject; use HiEvents\DomainObjects\AccountSsoDomainDomainObject; use HiEvents\DomainObjects\AccountUserDomainObject; +use HiEvents\DomainObjects\Enums\AccountSsoConnectionType; use HiEvents\DomainObjects\Enums\AuthMethod; use HiEvents\DomainObjects\Enums\Role; use HiEvents\DomainObjects\Enums\SsoSignInRequirement; @@ -76,6 +77,21 @@ public function assertSignInAllowed(UserDomainObject $user, int $accountId, Auth } } + /** + * @throws SsoRequiredException + */ + public function assertMemberSignInAllowed(UserDomainObject $user, int $accountId, AuthContext $context): void + { + if (! $this->isTokenAllowed($user->getId(), $user->getEmail(), $accountId, $context->method->value, $context->ssoConnectionId)) { + throw new SsoRequiredException($user->getEmail()); + } + } + + public function requiresSsoToJoin(int $userId, string $email, int $accountId): bool + { + return ! $this->isTokenAllowed($userId, $email, $accountId, AuthMethod::PASSWORD->value, null); + } + /** * @throws SsoRequiredException */ @@ -189,9 +205,9 @@ public function annotateSignInRequirements(int $accountId, Collection $users): v $canUseConnection = ! in_array($user->getId(), $withoutConnection, true); $accountUser?->setSsoSignInRequirement(match (true) { - $onVerifiedDomain && $accountUser->getSsoEnforced() && $canUseConnection && ! $accountUser->getIsAccountOwner() => SsoSignInRequirement::SSO, - $onVerifiedDomain && ! $accountUser->getIsAccountOwner() => SsoSignInRequirement::EXEMPT, - default => SsoSignInRequirement::PASSWORD, + ! $onVerifiedDomain || ! $canUseConnection || $accountUser->getIsAccountOwner() => SsoSignInRequirement::PASSWORD, + $accountUser->getSsoEnforced() => SsoSignInRequirement::SSO, + default => SsoSignInRequirement::EXEMPT, }); }); } @@ -235,14 +251,21 @@ public function enable(AccountSsoConnectionDomainObject $connection, array $exem { $this->assertCanEnable($connection); + $members = $this->members($connection->getAccountId()); + $withoutConnection = $this->usersWhoCannotUseConnection( + $connection, + $members->map(fn (AccountUserDomainObject $accountUser) => $accountUser->getUserId())->all(), + ); $newlyEnforcedUserIds = []; - foreach ($this->members($connection->getAccountId()) as $accountUser) { + foreach ($members as $accountUser) { $enforce = ! $accountUser->getIsAccountOwner() && ! in_array($accountUser->getId(), $exemptAccountUserIds, true) && $this->domainService->isEmailOnUsableDomain($connection->getAccountId(), $accountUser->getUser()?->getEmail()); - if ($enforce && ! ($connection->getEnforceSso() && $accountUser->getSsoEnforced())) { + if ($enforce + && ! in_array($accountUser->getUserId(), $withoutConnection, true) + && ! ($connection->getEnforceSso() && $accountUser->getSsoEnforced())) { $newlyEnforcedUserIds[] = $accountUser->getUserId(); } @@ -280,7 +303,12 @@ public function setMemberEnforced(int $accountId, int $accountUserId, bool $enfo $this->accountUserRepository->updateWhere(['sso_enforced' => $enforced], ['id' => $accountUserId]); - if ($enforced && ! $accountUser->getSsoEnforced() && $this->findConnection($accountId)?->isEnforcing()) { + $connection = $this->findConnection($accountId); + + if ($enforced + && ! $accountUser->getSsoEnforced() + && $connection?->isEnforcing() + && $this->usersWhoCannotUseConnection($connection, [$accountUser->getUserId()]) === []) { $this->sessionRevocationService->revokeAll($accountUser->getUserId(), $accountId); } } @@ -337,23 +365,37 @@ private function usersWhoCannotUseConnection(?AccountSsoConnectionDomainObject $ return []; } - $protectedUserIds = $this->accountUserRepository - ->findWhere([['user_id', 'in', $userIds]]) - ->filter(fn (AccountUserDomainObject $accountUser) => $accountUser->getIsAccountOwner() || $accountUser->getRole() === Role::SUPERADMIN->name) + $accountUsers = $this->accountUserRepository->findWhere([['user_id', 'in', $userIds]]); + $superAdminIds = $accountUsers + ->filter(fn (AccountUserDomainObject $accountUser) => $accountUser->getRole() === Role::SUPERADMIN->name) ->map(fn (AccountUserDomainObject $accountUser) => $accountUser->getUserId()) ->unique(); + $ownerIds = $accountUsers + ->filter(fn (AccountUserDomainObject $accountUser) => $accountUser->getIsAccountOwner()) + ->map(fn (AccountUserDomainObject $accountUser) => $accountUser->getUserId()) + ->unique() + ->diff($superAdminIds); - if ($protectedUserIds->isEmpty()) { - return []; + $linkedOwnerIds = $ownerIds->isEmpty() ? collect() : $this->userIdentityRepository + ->findWhere([['user_id', 'in', $ownerIds->all()]]) + ->filter(fn (UserIdentityDomainObject $identity) => $this->identitySignsInThrough($identity, $connection)) + ->map(fn (UserIdentityDomainObject $identity) => $identity->getUserId()); + + return $superAdminIds->merge($ownerIds->diff($linkedOwnerIds))->values()->all(); + } + + private function identitySignsInThrough(UserIdentityDomainObject $identity, AccountSsoConnectionDomainObject $connection): bool + { + if ($identity->getAccountSsoConnectionId() === $connection->getId()) { + return true; } - $linkedUserIds = $this->userIdentityRepository - ->findWhere([['user_id', 'in', $protectedUserIds->all()]]) - ->filter(fn (UserIdentityDomainObject $identity) => $identity->getAccountSsoConnectionId() === $connection->getId() - || $identity->getIssuer() === $connection->getIssuer()) - ->map(fn (UserIdentityDomainObject $identity) => $identity->getUserId()); + if ($identity->getIssuer() !== $connection->getIssuer()) { + return false; + } - return $protectedUserIds->diff($linkedUserIds)->values()->all(); + return $connection->getConnectionType() !== AccountSsoConnectionType::GOOGLE_WORKSPACE + || ($identity->getEmail() !== null && $this->domainService->domainOf($identity->getEmail()) === $connection->getHostedDomain()); } /** diff --git a/backend/lang/de.json b/backend/lang/de.json index b643b7b893..a07259796f 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Google-Tag-Manager-Container können nur im Dashboard hinzugefügt werden, nicht mit einem API-Schlüssel.", "You signed in without single sign-on": "Sie haben sich ohne Single Sign-On angemeldet", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Falls Sie das nicht waren, ändern Sie sofort Ihr Passwort und richten Sie die Zwei-Faktor-Authentifizierung mit einer neuen Authenticator-App erneut ein.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Diese Person besitzt ein Konto, das Single Sign-On verlangt. Die Zwei-Faktor-Authentifizierung ist ihre Notfall-Anmeldung. Bitten Sie sie daher, die Single-Sign-On-Pflicht zuerst zu beenden." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Diese Person besitzt ein Konto, das Single Sign-On verlangt. Die Zwei-Faktor-Authentifizierung ist ihre Notfall-Anmeldung. Bitten Sie sie daher, die Single-Sign-On-Pflicht zuerst zu beenden.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail wurde als Administrator zu Ihrem :appName-Konto eingeladen. Der Zugriff wird gewährt, sobald die Einladung angenommen wird. Administratoren können alle Veranstaltungen, das Team, Zahlungen und Kontoeinstellungen verwalten.", + "Enter the client secret again when you change the identity provider details.": "Geben Sie das Client Secret erneut ein, wenn Sie die Angaben zum Identity Provider ändern.", + "If you did not expect this, remove the invitation and contact support.": "Wenn Sie das nicht erwartet haben, entfernen Sie die Einladung und wenden Sie sich an den Support.", + "A gallery can have up to :max photos.": "Eine Galerie kann bis zu :max Fotos enthalten.", + "An FAQ section can have up to :max questions.": "Ein FAQ-Abschnitt kann bis zu :max Fragen enthalten.", + "Every FAQ needs a question.": "Jeder FAQ-Eintrag braucht eine Frage.", + "Every sponsor needs a logo.": "Jeder Sponsor braucht ein Logo.", + "FAQ answers can be up to :max characters.": "FAQ-Antworten dürfen höchstens :max Zeichen lang sein.", + "FAQ questions can be up to :max characters.": "FAQ-Fragen dürfen höchstens :max Zeichen lang sein.", + "Section headings can be up to :max characters.": "Abschnittsüberschriften dürfen höchstens :max Zeichen lang sein.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Etwas in Ihren Homepage-Abschnitten konnte nicht gespeichert werden. Laden Sie die Seite neu und versuchen Sie es erneut.", + "Sponsor links can be up to :max characters.": "Sponsoren-Links dürfen höchstens :max Zeichen lang sein.", + "Sponsor names can be up to :max characters.": "Sponsorennamen dürfen höchstens :max Zeichen lang sein.", + "Text sections can be up to :max characters.": "Textabschnitte dürfen höchstens :max Zeichen lang sein.", + "Video links can be up to :max characters.": "Videolinks dürfen höchstens :max Zeichen lang sein.", + "You can add up to :max sections.": "Sie können bis zu :max Abschnitte hinzufügen.", + "You can add up to :max sponsors.": "Sie können bis zu :max Sponsoren hinzufügen.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Anweisungen für Offline-Zahlungen können nur im Dashboard geändert werden, nicht mit einem API-Schlüssel.", + "Only members who can manage payments can change the offline payment instructions.": "Nur Mitglieder, die Zahlungen verwalten dürfen, können die Anweisungen für Offline-Zahlungen ändern.", + "Sign in with your email and password instead.": "Melden Sie sich stattdessen mit Ihrer E-Mail-Adresse und Ihrem Passwort an.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Dieses Ticket ist ausverkauft, daher kann dieser Teilnehmer nicht reaktiviert werden. Erhöhen Sie die Menge und versuchen Sie es erneut." } diff --git a/backend/lang/el.json b/backend/lang/el.json index 5452728ff4..02ecdf1db4 100644 --- a/backend/lang/el.json +++ b/backend/lang/el.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Τα κοντέινερ του Google Tag Manager προστίθενται μόνο από τον πίνακα ελέγχου, όχι με κλειδί API.", "You signed in without single sign-on": "Συνδεθήκατε χωρίς ενιαία σύνδεση (SSO)", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Αν δεν ήσασταν εσείς, αλλάξτε αμέσως τον κωδικό πρόσβασής σας και ρυθμίστε ξανά τον έλεγχο ταυτότητας δύο παραγόντων με νέα εφαρμογή επαλήθευσης.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Αυτός ο χρήστης είναι κάτοχος λογαριασμού που απαιτεί ενιαία σύνδεση. Ο έλεγχος ταυτότητας δύο παραγόντων είναι η εφεδρική του σύνδεση, οπότε ζητήστε του πρώτα να σταματήσει την υποχρεωτική ενιαία σύνδεση." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Αυτός ο χρήστης είναι κάτοχος λογαριασμού που απαιτεί ενιαία σύνδεση. Ο έλεγχος ταυτότητας δύο παραγόντων είναι η εφεδρική του σύνδεση, οπότε ζητήστε του πρώτα να σταματήσει την υποχρεωτική ενιαία σύνδεση.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": "Στάλθηκε πρόσκληση στο :newAdminEmail για να γίνει διαχειριστής του λογαριασμού σας στο :appName. Θα αποκτήσει πρόσβαση μόλις αποδεχτεί την πρόσκληση. Οι διαχειριστές μπορούν να διαχειρίζονται όλες τις εκδηλώσεις, την ομάδα, τις πληρωμές και τις ρυθμίσεις του λογαριασμού.", + "Enter the client secret again when you change the identity provider details.": "Εισάγετε ξανά το μυστικό πελάτη όταν αλλάζετε τα στοιχεία του παρόχου ταυτότητας.", + "If you did not expect this, remove the invitation and contact support.": "Αν δεν το περιμένατε, αφαιρέστε την πρόσκληση και επικοινωνήστε με την υποστήριξη.", + "A gallery can have up to :max photos.": "Μια συλλογή μπορεί να έχει έως :max φωτογραφίες.", + "An FAQ section can have up to :max questions.": "Μια ενότητα συχνών ερωτήσεων μπορεί να έχει έως :max ερωτήσεις.", + "Every FAQ needs a question.": "Κάθε στοιχείο των συχνών ερωτήσεων χρειάζεται μια ερώτηση.", + "Every sponsor needs a logo.": "Κάθε χορηγός χρειάζεται ένα λογότυπο.", + "FAQ answers can be up to :max characters.": "Οι απαντήσεις των συχνών ερωτήσεων μπορούν να έχουν έως :max χαρακτήρες.", + "FAQ questions can be up to :max characters.": "Οι συχνές ερωτήσεις μπορούν να έχουν έως :max χαρακτήρες.", + "Section headings can be up to :max characters.": "Οι επικεφαλίδες ενοτήτων μπορούν να έχουν έως :max χαρακτήρες.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Δεν ήταν δυνατή η αποθήκευση κάποιου στοιχείου στις ενότητες της αρχικής σας σελίδας. Ανανεώστε τη σελίδα και δοκιμάστε ξανά.", + "Sponsor links can be up to :max characters.": "Οι σύνδεσμοι χορηγών μπορούν να έχουν έως :max χαρακτήρες.", + "Sponsor names can be up to :max characters.": "Τα ονόματα χορηγών μπορούν να έχουν έως :max χαρακτήρες.", + "Text sections can be up to :max characters.": "Οι ενότητες κειμένου μπορούν να έχουν έως :max χαρακτήρες.", + "Video links can be up to :max characters.": "Οι σύνδεσμοι βίντεο μπορούν να έχουν έως :max χαρακτήρες.", + "You can add up to :max sections.": "Μπορείτε να προσθέσετε έως :max ενότητες.", + "You can add up to :max sponsors.": "Μπορείτε να προσθέσετε έως :max χορηγούς.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Οι οδηγίες για τις εκτός σύνδεσης πληρωμές αλλάζουν μόνο από τον πίνακα ελέγχου, όχι με κλειδί API.", + "Only members who can manage payments can change the offline payment instructions.": "Μόνο τα μέλη που μπορούν να διαχειρίζονται πληρωμές μπορούν να αλλάξουν τις οδηγίες για τις εκτός σύνδεσης πληρωμές.", + "Sign in with your email and password instead.": "Συνδεθείτε με το email και τον κωδικό σας.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Αυτό το εισιτήριο έχει εξαντληθεί, οπότε ο συμμετέχων δεν μπορεί να επανενεργοποιηθεί. Αυξήστε την ποσότητά του και δοκιμάστε ξανά." } diff --git a/backend/lang/es.json b/backend/lang/es.json index 005c1c6a67..ec3c37fe72 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -1217,5 +1217,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Los contenedores de Google Tag Manager solo se pueden añadir desde el panel, no con una clave de API.", "You signed in without single sign-on": "Has iniciado sesión sin el inicio de sesión único", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Si no fuiste tú, cambia tu contraseña y vuelve a configurar la autenticación en dos pasos con una nueva aplicación de autenticación de inmediato.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Este usuario es propietario de una cuenta que exige el inicio de sesión único. La autenticación en dos pasos es su acceso de respaldo, así que pídele primero que deje de exigir el inicio de sesión único." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Este usuario es propietario de una cuenta que exige el inicio de sesión único. La autenticación en dos pasos es su acceso de respaldo, así que pídele primero que deje de exigir el inicio de sesión único.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": "Se ha invitado a :newAdminEmail como administrador de tu cuenta de :appName. Tendrá acceso cuando acepte la invitación. Los administradores pueden gestionar todos los eventos, el equipo, los pagos y la configuración de la cuenta.", + "Enter the client secret again when you change the identity provider details.": "Vuelve a introducir el client secret cuando cambies los datos del proveedor de identidad.", + "If you did not expect this, remove the invitation and contact support.": "Si no lo esperabas, elimina la invitación y contacta con soporte.", + "A gallery can have up to :max photos.": "Una galería puede tener hasta :max fotos.", + "An FAQ section can have up to :max questions.": "Una sección de preguntas frecuentes puede tener hasta :max preguntas.", + "Every FAQ needs a question.": "Cada elemento de la sección de preguntas frecuentes necesita una pregunta.", + "Every sponsor needs a logo.": "Cada patrocinador necesita un logotipo.", + "FAQ answers can be up to :max characters.": "Las respuestas de la sección de preguntas frecuentes pueden tener hasta :max caracteres.", + "FAQ questions can be up to :max characters.": "Las preguntas de la sección de preguntas frecuentes pueden tener hasta :max caracteres.", + "Section headings can be up to :max characters.": "Los títulos de sección pueden tener hasta :max caracteres.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "No se pudo guardar algo de las secciones de tu página de inicio. Recarga la página e inténtalo de nuevo.", + "Sponsor links can be up to :max characters.": "Los enlaces de patrocinadores pueden tener hasta :max caracteres.", + "Sponsor names can be up to :max characters.": "Los nombres de patrocinadores pueden tener hasta :max caracteres.", + "Text sections can be up to :max characters.": "Las secciones de texto pueden tener hasta :max caracteres.", + "Video links can be up to :max characters.": "Los enlaces de vídeo pueden tener hasta :max caracteres.", + "You can add up to :max sections.": "Puedes añadir hasta :max secciones.", + "You can add up to :max sponsors.": "Puedes añadir hasta :max patrocinadores.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Las instrucciones de pago offline solo se pueden cambiar desde el panel, no con una clave de API.", + "Only members who can manage payments can change the offline payment instructions.": "Solo los miembros que pueden gestionar los pagos pueden cambiar las instrucciones de pago offline.", + "Sign in with your email and password instead.": "Inicia sesión con tu correo electrónico y contraseña.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Esta entrada está agotada, por lo que no se puede reactivar a este asistente. Aumenta su cantidad e inténtalo de nuevo." } diff --git a/backend/lang/fi.json b/backend/lang/fi.json index d110375cbd..0d0aa99043 100644 --- a/backend/lang/fi.json +++ b/backend/lang/fi.json @@ -1365,5 +1365,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Google Tag Manager -säilöjä voi lisätä vain hallintapaneelista, ei API-avaimella.", "You signed in without single sign-on": "Kirjauduit sisään ilman kertakirjautumista", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Jos tämä et ollut sinä, vaihda salasanasi ja ota kaksivaiheinen tunnistautuminen uudelleen käyttöön uudella todennussovelluksella heti.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Tämä käyttäjä omistaa tilin, joka vaatii kertakirjautumisen. Kaksivaiheinen tunnistautuminen on hänen varakirjautumisensa, joten pyydä häntä ensin lopettamaan kertakirjautumisen vaatiminen." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Tämä käyttäjä omistaa tilin, joka vaatii kertakirjautumisen. Kaksivaiheinen tunnistautuminen on hänen varakirjautumisensa, joten pyydä häntä ensin lopettamaan kertakirjautumisen vaatiminen.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail kutsuttiin :appName-tilisi järjestelmänvalvojaksi. Hän saa pääsyn, kun hyväksyy kutsun. Järjestelmänvalvojat voivat hallita kaikkia tapahtumia, tiimiä, maksuja ja tilin asetuksia.", + "Enter the client secret again when you change the identity provider details.": "Anna asiakassalaisuus uudelleen, kun muutat identiteetintarjoajan tietoja.", + "If you did not expect this, remove the invitation and contact support.": "Jos et odottanut tätä, poista kutsu ja ota yhteyttä tukeen.", + "A gallery can have up to :max photos.": "Galleriassa voi olla enintään :max kuvaa.", + "An FAQ section can have up to :max questions.": "UKK-osiossa voi olla enintään :max kysymystä.", + "Every FAQ needs a question.": "Jokaisessa UKK-kohdassa on oltava kysymys.", + "Every sponsor needs a logo.": "Jokaisella sponsorilla on oltava logo.", + "FAQ answers can be up to :max characters.": "UKK-vastaukset voivat olla enintään :max merkkiä.", + "FAQ questions can be up to :max characters.": "UKK-kysymykset voivat olla enintään :max merkkiä.", + "Section headings can be up to :max characters.": "Osioiden otsikot voivat olla enintään :max merkkiä.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Osaa etusivun osioistasi ei voitu tallentaa. Lataa sivu uudelleen ja yritä uudelleen.", + "Sponsor links can be up to :max characters.": "Sponsorien linkit voivat olla enintään :max merkkiä.", + "Sponsor names can be up to :max characters.": "Sponsorien nimet voivat olla enintään :max merkkiä.", + "Text sections can be up to :max characters.": "Tekstiosiot voivat olla enintään :max merkkiä.", + "Video links can be up to :max characters.": "Videolinkit voivat olla enintään :max merkkiä.", + "You can add up to :max sections.": "Voit lisätä enintään :max osiota.", + "You can add up to :max sponsors.": "Voit lisätä enintään :max sponsoria.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Offline-maksun ohjeita voi muuttaa vain hallintapaneelista, ei API-avaimella.", + "Only members who can manage payments can change the offline payment instructions.": "Vain jäsenet, joilla on oikeus hallita maksuja, voivat muuttaa offline-maksun ohjeita.", + "Sign in with your email and password instead.": "Kirjaudu sen sijaan sisään sähköpostiosoitteellasi ja salasanallasi.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Tämä lippu on myyty loppuun, joten tätä osallistujaa ei voi aktivoida uudelleen. Kasvata lipun määrää ja yritä uudelleen." } diff --git a/backend/lang/fr.json b/backend/lang/fr.json index 14f1faf93f..ef5c036c75 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Les conteneurs Google Tag Manager ne peuvent être ajoutés que depuis le tableau de bord, pas avec une clé API.", "You signed in without single sign-on": "Vous vous êtes connecté sans l'authentification unique", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Si ce n'était pas vous, changez immédiatement votre mot de passe et configurez à nouveau l'authentification à deux facteurs avec une nouvelle application d'authentification.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Cet utilisateur possède un compte qui exige l'authentification unique. L'authentification à deux facteurs est sa connexion de secours : demandez-lui d'abord de cesser d'exiger l'authentification unique." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Cet utilisateur possède un compte qui exige l'authentification unique. L'authentification à deux facteurs est sa connexion de secours : demandez-lui d'abord de cesser d'exiger l'authentification unique.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail a reçu une invitation en tant qu'administrateur de votre compte :appName. L'accès sera accordé une fois l'invitation acceptée. Les administrateurs peuvent gérer tous les événements, l'équipe, les paiements et les paramètres du compte.", + "Enter the client secret again when you change the identity provider details.": "Saisissez à nouveau le client secret lorsque vous modifiez les informations du fournisseur d'identité.", + "If you did not expect this, remove the invitation and contact support.": "Si vous ne vous y attendiez pas, supprimez l'invitation et contactez le support.", + "A gallery can have up to :max photos.": "Une galerie peut contenir jusqu'à :max photos.", + "An FAQ section can have up to :max questions.": "Une section FAQ peut contenir jusqu'à :max questions.", + "Every FAQ needs a question.": "Chaque FAQ doit comporter une question.", + "Every sponsor needs a logo.": "Chaque sponsor doit avoir un logo.", + "FAQ answers can be up to :max characters.": "Les réponses de la FAQ ne peuvent pas dépasser :max caractères.", + "FAQ questions can be up to :max characters.": "Les questions de la FAQ ne peuvent pas dépasser :max caractères.", + "Section headings can be up to :max characters.": "Les titres de section ne peuvent pas dépasser :max caractères.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Une partie de vos sections de page d'accueil n'a pas pu être enregistrée. Rechargez la page et réessayez.", + "Sponsor links can be up to :max characters.": "Les liens de sponsors ne peuvent pas dépasser :max caractères.", + "Sponsor names can be up to :max characters.": "Les noms de sponsors ne peuvent pas dépasser :max caractères.", + "Text sections can be up to :max characters.": "Les sections de texte ne peuvent pas dépasser :max caractères.", + "Video links can be up to :max characters.": "Les liens vidéo ne peuvent pas dépasser :max caractères.", + "You can add up to :max sections.": "Vous pouvez ajouter jusqu'à :max sections.", + "You can add up to :max sponsors.": "Vous pouvez ajouter jusqu'à :max sponsors.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Les instructions de paiement hors ligne ne peuvent être modifiées que depuis le tableau de bord, pas avec une clé API.", + "Only members who can manage payments can change the offline payment instructions.": "Seuls les membres autorisés à gérer les paiements peuvent modifier les instructions de paiement hors ligne.", + "Sign in with your email and password instead.": "Connectez-vous plutôt avec votre adresse e-mail et votre mot de passe.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Ce billet est épuisé, ce participant ne peut donc pas être réactivé. Augmentez sa quantité et réessayez." } diff --git a/backend/lang/hu.json b/backend/lang/hu.json index fd0dab1133..75e7dbee40 100644 --- a/backend/lang/hu.json +++ b/backend/lang/hu.json @@ -1182,5 +1182,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Google Tag Manager-tárolót csak az irányítópulton lehet hozzáadni, API-kulccsal nem.", "You signed in without single sign-on": "Egyszeri bejelentkezés nélkül jelentkeztél be", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Ha nem te voltál, azonnal változtasd meg a jelszavad, és állítsd be újra a kétlépcsős azonosítást egy új hitelesítő alkalmazással.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Ez a felhasználó egy olyan fiók tulajdonosa, amely egyszeri bejelentkezést követel meg. A kétlépcsős azonosítás a tartalék bejelentkezése, ezért először kérd meg, hogy kapcsolja ki az egyszeri bejelentkezés kötelezővé tételét." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Ez a felhasználó egy olyan fiók tulajdonosa, amely egyszeri bejelentkezést követel meg. A kétlépcsős azonosítás a tartalék bejelentkezése, ezért először kérd meg, hogy kapcsolja ki az egyszeri bejelentkezés kötelezővé tételét.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail meghívást kapott adminisztrátorként a(z) :appName fiókodba. A hozzáférést a meghívás elfogadása után kapja meg. Az adminisztrátorok kezelhetik az összes eseményt, a csapatot, a fizetéseket és a fiókbeállításokat.", + "Enter the client secret again when you change the identity provider details.": "Ha módosítja az identitásszolgáltató adatait, adja meg újra a client secretet.", + "If you did not expect this, remove the invitation and contact support.": "Ha erre nem számítottál, töröld a meghívást, és fordulj az ügyfélszolgálathoz.", + "A gallery can have up to :max photos.": "Egy galériában legfeljebb :max fotó lehet.", + "An FAQ section can have up to :max questions.": "Egy GYIK-szakaszban legfeljebb :max kérdés lehet.", + "Every FAQ needs a question.": "Minden GYIK-elemhez meg kell adni egy kérdést.", + "Every sponsor needs a logo.": "Minden szponzorhoz meg kell adni egy logót.", + "FAQ answers can be up to :max characters.": "A GYIK-válaszok legfeljebb :max karakter hosszúak lehetnek.", + "FAQ questions can be up to :max characters.": "A GYIK-kérdések legfeljebb :max karakter hosszúak lehetnek.", + "Section headings can be up to :max characters.": "A szakaszcímsorok legfeljebb :max karakter hosszúak lehetnek.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "A honlapszakaszok egy részét nem sikerült menteni. Töltse újra az oldalt, és próbálja újra.", + "Sponsor links can be up to :max characters.": "A szponzorlinkek legfeljebb :max karakter hosszúak lehetnek.", + "Sponsor names can be up to :max characters.": "A szponzornevek legfeljebb :max karakter hosszúak lehetnek.", + "Text sections can be up to :max characters.": "A szövegszakaszok legfeljebb :max karakter hosszúak lehetnek.", + "Video links can be up to :max characters.": "A videólinkek legfeljebb :max karakter hosszúak lehetnek.", + "You can add up to :max sections.": "Legfeljebb :max szakaszt adhat hozzá.", + "You can add up to :max sponsors.": "Legfeljebb :max szponzort adhat hozzá.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Az offline fizetési utasításokat csak az irányítópulton lehet módosítani, API-kulccsal nem.", + "Only members who can manage payments can change the offline payment instructions.": "Az offline fizetési utasításokat csak a fizetések kezelésére jogosult tagok módosíthatják.", + "Sign in with your email and password instead.": "Jelentkezzen be inkább e-mail-címével és jelszavával.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Ez a jegy elfogyott, ezért a résztvevő nem aktiválható újra. Növelje a jegy mennyiségét, és próbálja újra." } diff --git a/backend/lang/it.json b/backend/lang/it.json index 2340f72538..5818b0f155 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -1178,5 +1178,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "I contenitori di Google Tag Manager si possono aggiungere solo dalla dashboard, non con una chiave API.", "You signed in without single sign-on": "Hai effettuato l'accesso senza single sign-on", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Se non sei stato tu, cambia subito la password e configura di nuovo l'autenticazione a due fattori con una nuova app di autenticazione.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Questo utente è proprietario di un account che richiede il single sign-on. L'autenticazione a due fattori è il suo accesso di emergenza, quindi chiedigli prima di smettere di richiedere il single sign-on." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Questo utente è proprietario di un account che richiede il single sign-on. L'autenticazione a due fattori è il suo accesso di emergenza, quindi chiedigli prima di smettere di richiedere il single sign-on.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": "Abbiamo invitato :newAdminEmail come amministratore del tuo account :appName. Avrà accesso dopo aver accettato l'invito. Gli amministratori possono gestire tutti gli eventi, il team, i pagamenti e le impostazioni dell'account.", + "Enter the client secret again when you change the identity provider details.": "Inserisci di nuovo il client secret quando modifichi i dettagli dell'identity provider.", + "If you did not expect this, remove the invitation and contact support.": "Se non te lo aspettavi, rimuovi l'invito e contatta l'assistenza.", + "A gallery can have up to :max photos.": "Una galleria può contenere fino a :max foto.", + "An FAQ section can have up to :max questions.": "Una sezione FAQ può contenere fino a :max domande.", + "Every FAQ needs a question.": "Ogni FAQ deve avere una domanda.", + "Every sponsor needs a logo.": "Ogni sponsor deve avere un logo.", + "FAQ answers can be up to :max characters.": "Le risposte delle FAQ possono contenere fino a :max caratteri.", + "FAQ questions can be up to :max characters.": "Le domande delle FAQ possono contenere fino a :max caratteri.", + "Section headings can be up to :max characters.": "I titoli delle sezioni possono contenere fino a :max caratteri.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Non è stato possibile salvare alcuni elementi delle sezioni della homepage. Ricarica la pagina e riprova.", + "Sponsor links can be up to :max characters.": "I link degli sponsor possono contenere fino a :max caratteri.", + "Sponsor names can be up to :max characters.": "I nomi degli sponsor possono contenere fino a :max caratteri.", + "Text sections can be up to :max characters.": "Le sezioni di testo possono contenere fino a :max caratteri.", + "Video links can be up to :max characters.": "I link dei video possono contenere fino a :max caratteri.", + "You can add up to :max sections.": "Puoi aggiungere fino a :max sezioni.", + "You can add up to :max sponsors.": "Puoi aggiungere fino a :max sponsor.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Le istruzioni per il pagamento offline possono essere modificate solo dalla dashboard, non con una chiave API.", + "Only members who can manage payments can change the offline payment instructions.": "Solo i membri che possono gestire i pagamenti possono modificare le istruzioni per il pagamento offline.", + "Sign in with your email and password instead.": "Accedi invece con la tua email e password.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Questo biglietto è esaurito, quindi il partecipante non può essere riattivato. Aumentane la quantità e riprova." } diff --git a/backend/lang/ja.json b/backend/lang/ja.json index 8b77cb06cf..1e8a3ebecc 100644 --- a/backend/lang/ja.json +++ b/backend/lang/ja.json @@ -1365,5 +1365,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Google タグ マネージャーのコンテナはダッシュボードからのみ追加でき、API キーでは追加できません。", "You signed in without single sign-on": "シングルサインオンを使わずにサインインしました", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "心当たりがない場合は、すぐにパスワードを変更し、新しい認証アプリで二要素認証を設定し直してください。", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "このユーザーはシングルサインオンを必須にしているアカウントの所有者です。二要素認証はバックアップのサインイン手段なので、先にシングルサインオンの必須設定を解除するよう依頼してください。" + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "このユーザーはシングルサインオンを必須にしているアカウントの所有者です。二要素認証はバックアップのサインイン手段なので、先にシングルサインオンの必須設定を解除するよう依頼してください。", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail が :appName アカウントの管理者として招待されました。招待が承諾されるとアクセスできるようになります。管理者はすべてのイベント、チーム、支払い、アカウント設定を管理できます。", + "Enter the client secret again when you change the identity provider details.": "IDプロバイダーの詳細を変更する場合は、クライアントシークレットを再入力してください。", + "If you did not expect this, remove the invitation and contact support.": "心当たりがない場合は、招待を取り消してサポートにお問い合わせください。", + "A gallery can have up to :max photos.": "ギャラリーには最大 :max 枚の写真を追加できます。", + "An FAQ section can have up to :max questions.": "よくある質問セクションには最大 :max 件の質問を追加できます。", + "Every FAQ needs a question.": "よくある質問の各項目には質問を入力してください。", + "Every sponsor needs a logo.": "各スポンサーにはロゴが必要です。", + "FAQ answers can be up to :max characters.": "よくある質問の回答は :max 文字以内で入力してください。", + "FAQ questions can be up to :max characters.": "よくある質問の質問文は :max 文字以内で入力してください。", + "Section headings can be up to :max characters.": "セクションの見出しは :max 文字以内で入力してください。", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "ホームページセクションの一部を保存できませんでした。ページを再読み込みして、もう一度お試しください。", + "Sponsor links can be up to :max characters.": "スポンサーのリンクは :max 文字以内で入力してください。", + "Sponsor names can be up to :max characters.": "スポンサー名は :max 文字以内で入力してください。", + "Text sections can be up to :max characters.": "テキストセクションは :max 文字以内で入力してください。", + "Video links can be up to :max characters.": "動画リンクは :max 文字以内で入力してください。", + "You can add up to :max sections.": "セクションは最大 :max 件まで追加できます。", + "You can add up to :max sponsors.": "スポンサーは最大 :max 件まで追加できます。", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "オフライン決済の案内はダッシュボードからのみ変更でき、APIキーでは変更できません。", + "Only members who can manage payments can change the offline payment instructions.": "オフライン決済の案内を変更できるのは、支払いを管理できるメンバーのみです。", + "Sign in with your email and password instead.": "代わりにメールアドレスとパスワードでサインインしてください。", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "このチケットは完売しているため、この参加者を再有効化できません。販売可能数を増やして、もう一度お試しください。" } diff --git a/backend/lang/ko.json b/backend/lang/ko.json index f80e76c153..d1185f79ff 100644 --- a/backend/lang/ko.json +++ b/backend/lang/ko.json @@ -1366,5 +1366,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Google 태그 관리자 컨테이너는 API 키가 아닌 대시보드에서만 추가할 수 있습니다.", "You signed in without single sign-on": "싱글 사인온 없이 로그인했습니다", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "본인이 아니라면 즉시 비밀번호를 변경하고 새 인증 앱으로 2단계 인증을 다시 설정하세요.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "이 사용자는 싱글 사인온을 필수로 하는 계정의 소유자입니다. 2단계 인증은 백업 로그인 수단이므로 먼저 싱글 사인온 필수 설정을 해제하도록 요청하세요." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "이 사용자는 싱글 사인온을 필수로 하는 계정의 소유자입니다. 2단계 인증은 백업 로그인 수단이므로 먼저 싱글 사인온 필수 설정을 해제하도록 요청하세요.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail 님이 :appName 계정의 관리자로 초대되었습니다. 초대를 수락하면 접근 권한이 부여됩니다. 관리자는 모든 이벤트, 팀, 결제 및 계정 설정을 관리할 수 있습니다.", + "Enter the client secret again when you change the identity provider details.": "ID 공급자 정보를 변경할 때는 클라이언트 시크릿을 다시 입력하세요.", + "If you did not expect this, remove the invitation and contact support.": "예상하지 못한 초대라면 초대를 삭제하고 지원팀에 문의하세요.", + "A gallery can have up to :max photos.": "갤러리에는 사진을 최대 :max장까지 넣을 수 있습니다.", + "An FAQ section can have up to :max questions.": "자주 묻는 질문 섹션에는 질문을 최대 :max개까지 추가할 수 있습니다.", + "Every FAQ needs a question.": "모든 자주 묻는 질문 항목에는 질문이 있어야 합니다.", + "Every sponsor needs a logo.": "모든 스폰서에는 로고가 있어야 합니다.", + "FAQ answers can be up to :max characters.": "자주 묻는 질문의 답변은 최대 :max자까지 입력할 수 있습니다.", + "FAQ questions can be up to :max characters.": "자주 묻는 질문의 질문은 최대 :max자까지 입력할 수 있습니다.", + "Section headings can be up to :max characters.": "섹션 제목은 최대 :max자까지 입력할 수 있습니다.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "홈페이지 섹션의 일부 내용을 저장하지 못했습니다. 페이지를 새로고침한 후 다시 시도하세요.", + "Sponsor links can be up to :max characters.": "스폰서 링크는 최대 :max자까지 입력할 수 있습니다.", + "Sponsor names can be up to :max characters.": "스폰서 이름은 최대 :max자까지 입력할 수 있습니다.", + "Text sections can be up to :max characters.": "텍스트 섹션은 최대 :max자까지 입력할 수 있습니다.", + "Video links can be up to :max characters.": "동영상 링크는 최대 :max자까지 입력할 수 있습니다.", + "You can add up to :max sections.": "섹션은 최대 :max개까지 추가할 수 있습니다.", + "You can add up to :max sponsors.": "스폰서는 최대 :max개까지 추가할 수 있습니다.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "무통장 입금 안내는 API 키가 아닌 대시보드에서만 변경할 수 있습니다.", + "Only members who can manage payments can change the offline payment instructions.": "결제를 관리할 수 있는 구성원만 무통장 입금 안내를 변경할 수 있습니다.", + "Sign in with your email and password instead.": "대신 이메일과 비밀번호로 로그인하세요.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "이 티켓이 매진되어 이 참석자를 다시 활성화할 수 없습니다. 티켓 수량을 늘린 후 다시 시도해 주세요." } diff --git a/backend/lang/nl.json b/backend/lang/nl.json index f05571bd1d..ff243a5dd7 100644 --- a/backend/lang/nl.json +++ b/backend/lang/nl.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Google Tag Manager-containers kun je alleen via het dashboard toevoegen, niet met een API-sleutel.", "You signed in without single sign-on": "Je bent ingelogd zonder single sign-on", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Was jij dit niet? Wijzig dan meteen je wachtwoord en stel tweestapsverificatie opnieuw in met een nieuwe authenticator-app.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Deze gebruiker is eigenaar van een account dat single sign-on verplicht. Tweestapsverificatie is hun noodtoegang, dus vraag hen eerst om single sign-on niet langer te verplichten." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Deze gebruiker is eigenaar van een account dat single sign-on verplicht. Tweestapsverificatie is hun noodtoegang, dus vraag hen eerst om single sign-on niet langer te verplichten.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail is uitgenodigd als beheerder van je :appName-account. Deze persoon krijgt toegang zodra de uitnodiging is geaccepteerd. Beheerders kunnen alle evenementen, het team, betalingen en accountinstellingen beheren.", + "Enter the client secret again when you change the identity provider details.": "Voer het client secret opnieuw in wanneer je de gegevens van de identity provider wijzigt.", + "If you did not expect this, remove the invitation and contact support.": "Als je dit niet verwachtte, verwijder dan de uitnodiging en neem contact op met support.", + "A gallery can have up to :max photos.": "Een galerij kan maximaal :max foto’s bevatten.", + "An FAQ section can have up to :max questions.": "Een FAQ-sectie kan maximaal :max vragen bevatten.", + "Every FAQ needs a question.": "Elke FAQ heeft een vraag nodig.", + "Every sponsor needs a logo.": "Elke sponsor heeft een logo nodig.", + "FAQ answers can be up to :max characters.": "FAQ-antwoorden mogen maximaal :max tekens bevatten.", + "FAQ questions can be up to :max characters.": "FAQ-vragen mogen maximaal :max tekens bevatten.", + "Section headings can be up to :max characters.": "Sectiekoppen mogen maximaal :max tekens bevatten.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Iets in je homepagesecties kon niet worden opgeslagen. Laad de pagina opnieuw en probeer het nog eens.", + "Sponsor links can be up to :max characters.": "Sponsorlinks mogen maximaal :max tekens bevatten.", + "Sponsor names can be up to :max characters.": "Sponsornamen mogen maximaal :max tekens bevatten.", + "Text sections can be up to :max characters.": "Tekstsecties mogen maximaal :max tekens bevatten.", + "Video links can be up to :max characters.": "Videolinks mogen maximaal :max tekens bevatten.", + "You can add up to :max sections.": "Je kunt maximaal :max secties toevoegen.", + "You can add up to :max sponsors.": "Je kunt maximaal :max sponsors toevoegen.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Instructies voor offline betalingen kun je alleen via het dashboard wijzigen, niet met een API-sleutel.", + "Only members who can manage payments can change the offline payment instructions.": "Alleen leden die betalingen mogen beheren, kunnen de instructies voor offline betalingen wijzigen.", + "Sign in with your email and password instead.": "Log in plaats daarvan in met je e-mailadres en wachtwoord.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Dit ticket is uitverkocht, dus deze deelnemer kan niet opnieuw worden geactiveerd. Verhoog het aantal en probeer het opnieuw." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index ae2af722f6..13e53f9bbd 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1365,5 +1365,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Kontenery Google Tag Manager można dodać tylko w panelu, a nie za pomocą klucza API.", "You signed in without single sign-on": "Zalogowano się bez logowania jednokrotnego", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Jeśli to nie Ty, natychmiast zmień hasło i ponownie skonfiguruj uwierzytelnianie dwuskładnikowe z nową aplikacją uwierzytelniającą.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Ten użytkownik jest właścicielem konta, które wymaga logowania jednokrotnego. Uwierzytelnianie dwuskładnikowe to jego zapasowy sposób logowania, więc najpierw poproś go o wyłączenie wymogu logowania jednokrotnego." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Ten użytkownik jest właścicielem konta, które wymaga logowania jednokrotnego. Uwierzytelnianie dwuskładnikowe to jego zapasowy sposób logowania, więc najpierw poproś go o wyłączenie wymogu logowania jednokrotnego.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": "Zaproszono :newAdminEmail jako administratora Twojego konta :appName. Dostęp zostanie przyznany po przyjęciu zaproszenia. Administratorzy mogą zarządzać wszystkimi wydarzeniami, zespołem, płatnościami i ustawieniami konta.", + "Enter the client secret again when you change the identity provider details.": "Wpisz ponownie client secret, gdy zmieniasz dane dostawcy tożsamości.", + "If you did not expect this, remove the invitation and contact support.": "Jeśli się tego nie spodziewasz, usuń zaproszenie i skontaktuj się z pomocą techniczną.", + "A gallery can have up to :max photos.": "Galeria może zawierać do :max zdjęć.", + "An FAQ section can have up to :max questions.": "Sekcja FAQ może zawierać maksymalnie :max pytań.", + "Every FAQ needs a question.": "Każdy element FAQ musi zawierać pytanie.", + "Every sponsor needs a logo.": "Każdy sponsor musi mieć logo.", + "FAQ answers can be up to :max characters.": "Odpowiedzi w FAQ mogą mieć maksymalnie :max znaków.", + "FAQ questions can be up to :max characters.": "Pytania w FAQ mogą mieć maksymalnie :max znaków.", + "Section headings can be up to :max characters.": "Nagłówki sekcji mogą mieć maksymalnie :max znaków.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Nie udało się zapisać części zmian w sekcjach strony głównej. Odśwież stronę i spróbuj ponownie.", + "Sponsor links can be up to :max characters.": "Linki sponsorów mogą mieć maksymalnie :max znaków.", + "Sponsor names can be up to :max characters.": "Nazwy sponsorów mogą mieć maksymalnie :max znaków.", + "Text sections can be up to :max characters.": "Sekcje tekstowe mogą mieć maksymalnie :max znaków.", + "Video links can be up to :max characters.": "Linki do wideo mogą mieć maksymalnie :max znaków.", + "You can add up to :max sections.": "Możesz dodać maksymalnie :max sekcji.", + "You can add up to :max sponsors.": "Możesz dodać maksymalnie :max sponsorów.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Instrukcje płatności offline można zmienić tylko w panelu, a nie za pomocą klucza API.", + "Only members who can manage payments can change the offline payment instructions.": "Tylko członkowie, którzy mogą zarządzać płatnościami, mogą zmieniać instrukcje płatności offline.", + "Sign in with your email and password instead.": "Zamiast tego zaloguj się za pomocą adresu e-mail i hasła.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Ten bilet jest wyprzedany, więc nie można ponownie aktywować tego uczestnika. Zwiększ liczbę dostępnych biletów i spróbuj ponownie." } diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index 9c7391b42a..6bfb918b0b 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Contêineres do Google Tag Manager só podem ser adicionados pelo painel, não com uma chave de API.", "You signed in without single sign-on": "Você entrou sem o login único", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Se não foi você, altere sua senha e configure a autenticação de dois fatores novamente com um novo aplicativo autenticador imediatamente.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Este usuário é proprietário de uma conta que exige login único. A autenticação de dois fatores é o acesso de emergência dele, então peça primeiro que deixe de exigir o login único." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Este usuário é proprietário de uma conta que exige login único. A autenticação de dois fatores é o acesso de emergência dele, então peça primeiro que deixe de exigir o login único.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail recebeu um convite para ser administrador da sua conta :appName. O acesso será liberado assim que o convite for aceito. Administradores podem gerenciar todos os eventos, a equipe, os pagamentos e as configurações da conta.", + "Enter the client secret again when you change the identity provider details.": "Digite o client secret novamente ao alterar os dados do provedor de identidade.", + "If you did not expect this, remove the invitation and contact support.": "Se você não esperava por isso, remova o convite e entre em contato com o suporte.", + "A gallery can have up to :max photos.": "Uma galeria pode ter até :max fotos.", + "An FAQ section can have up to :max questions.": "Uma seção de perguntas frequentes pode ter até :max perguntas.", + "Every FAQ needs a question.": "Cada pergunta frequente precisa de uma pergunta.", + "Every sponsor needs a logo.": "Cada patrocinador precisa de um logo.", + "FAQ answers can be up to :max characters.": "As respostas das perguntas frequentes podem ter até :max caracteres.", + "FAQ questions can be up to :max characters.": "As perguntas frequentes podem ter até :max caracteres.", + "Section headings can be up to :max characters.": "Os títulos das seções podem ter até :max caracteres.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Não foi possível salvar algo nas seções da sua página inicial. Recarregue a página e tente novamente.", + "Sponsor links can be up to :max characters.": "Os links de patrocinadores podem ter até :max caracteres.", + "Sponsor names can be up to :max characters.": "Os nomes de patrocinadores podem ter até :max caracteres.", + "Text sections can be up to :max characters.": "As seções de texto podem ter até :max caracteres.", + "Video links can be up to :max characters.": "Os links de vídeo podem ter até :max caracteres.", + "You can add up to :max sections.": "Você pode adicionar até :max seções.", + "You can add up to :max sponsors.": "Você pode adicionar até :max patrocinadores.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "As instruções de pagamento offline só podem ser alteradas pelo painel, não com uma chave de API.", + "Only members who can manage payments can change the offline payment instructions.": "Somente membros que podem gerenciar pagamentos podem alterar as instruções de pagamento offline.", + "Sign in with your email and password instead.": "Em vez disso, entre com seu e-mail e senha.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Este ingresso está esgotado, então este participante não pode ser reativado. Aumente a quantidade e tente novamente." } diff --git a/backend/lang/pt.json b/backend/lang/pt.json index a3ef2f2406..d1d3676b3d 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Os contentores do Google Tag Manager só podem ser adicionados no painel, não com uma chave de API.", "You signed in without single sign-on": "Iniciou sessão sem o início de sessão único", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Se não foi você, altere imediatamente a sua palavra-passe e configure novamente a autenticação de dois fatores com uma nova aplicação de autenticação.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Este utilizador é proprietário de uma conta que exige o início de sessão único. A autenticação de dois fatores é o seu acesso de emergência, por isso peça-lhe primeiro que deixe de exigir o início de sessão único." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Este utilizador é proprietário de uma conta que exige o início de sessão único. A autenticação de dois fatores é o seu acesso de emergência, por isso peça-lhe primeiro que deixe de exigir o início de sessão único.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail foi convidado como administrador da sua conta :appName. Terá acesso assim que aceitar o convite. Os administradores podem gerir todos os eventos, a equipa, os pagamentos e as definições da conta.", + "Enter the client secret again when you change the identity provider details.": "Introduza novamente o client secret quando alterar os dados do fornecedor de identidade.", + "If you did not expect this, remove the invitation and contact support.": "Se não estava à espera disto, remova o convite e contacte o suporte.", + "A gallery can have up to :max photos.": "Uma galeria pode ter até :max fotos.", + "An FAQ section can have up to :max questions.": "Uma secção de perguntas frequentes pode ter até :max perguntas.", + "Every FAQ needs a question.": "Cada entrada das perguntas frequentes precisa de uma pergunta.", + "Every sponsor needs a logo.": "Cada patrocinador precisa de um logótipo.", + "FAQ answers can be up to :max characters.": "As respostas das perguntas frequentes podem ter até :max caracteres.", + "FAQ questions can be up to :max characters.": "As perguntas frequentes podem ter até :max caracteres.", + "Section headings can be up to :max characters.": "Os títulos das secções podem ter até :max caracteres.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Não foi possível guardar algo nas secções da sua página inicial. Recarregue a página e tente novamente.", + "Sponsor links can be up to :max characters.": "As ligações de patrocinadores podem ter até :max caracteres.", + "Sponsor names can be up to :max characters.": "Os nomes dos patrocinadores podem ter até :max caracteres.", + "Text sections can be up to :max characters.": "As secções de texto podem ter até :max caracteres.", + "Video links can be up to :max characters.": "As ligações de vídeo podem ter até :max caracteres.", + "You can add up to :max sections.": "Pode adicionar até :max secções.", + "You can add up to :max sponsors.": "Pode adicionar até :max patrocinadores.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "As instruções de pagamento offline só podem ser alteradas no painel, não com uma chave de API.", + "Only members who can manage payments can change the offline payment instructions.": "Apenas os membros que podem gerir pagamentos podem alterar as instruções de pagamento offline.", + "Sign in with your email and password instead.": "Em vez disso, inicie sessão com o seu e-mail e palavra-passe.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Este bilhete está esgotado, pelo que este participante não pode ser reativado. Aumente a quantidade do bilhete e tente novamente." } diff --git a/backend/lang/ru.json b/backend/lang/ru.json index 169f2f6b43..cc4fd7ec78 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -1117,5 +1117,28 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Контейнеры Google Tag Manager можно добавить только в панели управления, а не с помощью API-ключа.", "You signed in without single sign-on": "Вы вошли без единого входа", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Если это были не вы, немедленно смените пароль и заново настройте двухфакторную аутентификацию в новом приложении-аутентификаторе.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Этот пользователь владеет аккаунтом, где обязателен единый вход. Двухфакторная аутентификация — его резервный способ входа, поэтому сначала попросите его отключить обязательный единый вход." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Этот пользователь владеет аккаунтом, где обязателен единый вход. Двухфакторная аутентификация — его резервный способ входа, поэтому сначала попросите его отключить обязательный единый вход.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": "Пользователь :newAdminEmail приглашён в качестве администратора вашего аккаунта :appName. Доступ появится после принятия приглашения. Администраторы могут управлять всеми мероприятиями, командой, платежами и настройками аккаунта.", + "Add a title or at least one person from the lineup": "Добавьте название или хотя бы одного человека из лайн-апа", + "Each person may only be added once": "Каждого человека можно добавить только один раз", + "Enter the client secret again when you change the identity provider details.": "Введите секрет клиента ещё раз, если меняете данные поставщика удостоверений.", + "If you did not expect this, remove the invitation and contact support.": "Если вы этого не ожидали, удалите приглашение и обратитесь в поддержку.", + "A gallery can have up to :max photos.": "В галерее может быть не более :max фото.", + "An FAQ section can have up to :max questions.": "В разделе FAQ может быть не более :max вопросов.", + "Every FAQ needs a question.": "У каждого пункта FAQ должен быть вопрос.", + "Every sponsor needs a logo.": "У каждого спонсора должен быть логотип.", + "FAQ answers can be up to :max characters.": "Ответы в FAQ могут содержать не более :max символов.", + "FAQ questions can be up to :max characters.": "Вопросы в FAQ могут содержать не более :max символов.", + "Section headings can be up to :max characters.": "Заголовки разделов могут содержать не более :max символов.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Не удалось сохранить часть разделов главной страницы. Обновите страницу и повторите попытку.", + "Sponsor links can be up to :max characters.": "Ссылки спонсоров могут содержать не более :max символов.", + "Sponsor names can be up to :max characters.": "Названия спонсоров могут содержать не более :max символов.", + "Text sections can be up to :max characters.": "Текстовые разделы могут содержать не более :max символов.", + "Video links can be up to :max characters.": "Ссылки на видео могут содержать не более :max символов.", + "You can add up to :max sections.": "Можно добавить не более :max разделов.", + "You can add up to :max sponsors.": "Можно добавить не более :max спонсоров.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Инструкции по офлайн-оплате можно изменить только в панели управления, а не с помощью API-ключа.", + "Only members who can manage payments can change the offline payment instructions.": "Изменять инструкции по офлайн-оплате могут только участники с правом управлять платежами.", + "Sign in with your email and password instead.": "Вместо этого войдите с помощью адреса электронной почты и пароля.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Этот билет распродан, поэтому участника нельзя активировать снова. Увеличьте количество билетов и повторите попытку." } diff --git a/backend/lang/se.json b/backend/lang/se.json index cba3bbedc1..36fed3a8e5 100644 --- a/backend/lang/se.json +++ b/backend/lang/se.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Google Tag Manager-behållare kan bara läggas till från instrumentpanelen, inte med en API-nyckel.", "You signed in without single sign-on": "Du loggade in utan enkel inloggning", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Om det inte var du, byt lösenord direkt och ställ in tvåfaktorsautentisering igen med en ny autentiseringsapp.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Den här användaren äger ett konto som kräver enkel inloggning. Tvåfaktorsautentisering är deras reservinloggning, så be dem först sluta kräva enkel inloggning." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Den här användaren äger ett konto som kräver enkel inloggning. Tvåfaktorsautentisering är deras reservinloggning, så be dem först sluta kräva enkel inloggning.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail har bjudits in som administratör för ditt :appName-konto. Personen får åtkomst när inbjudan har accepterats. Administratörer kan hantera alla evenemang, teamet, betalningar och kontoinställningar.", + "Enter the client secret again when you change the identity provider details.": "Ange klienthemligheten igen när du ändrar identitetsleverantörens uppgifter.", + "If you did not expect this, remove the invitation and contact support.": "Om du inte förväntade dig detta, ta bort inbjudan och kontakta supporten.", + "A gallery can have up to :max photos.": "Ett galleri kan innehålla upp till :max foton.", + "An FAQ section can have up to :max questions.": "En sektion med vanliga frågor kan ha upp till :max frågor.", + "Every FAQ needs a question.": "Varje post under Vanliga frågor behöver en fråga.", + "Every sponsor needs a logo.": "Varje sponsor behöver en logotyp.", + "FAQ answers can be up to :max characters.": "Svar under Vanliga frågor får vara upp till :max tecken.", + "FAQ questions can be up to :max characters.": "Frågor under Vanliga frågor får vara upp till :max tecken.", + "Section headings can be up to :max characters.": "Sektionsrubriker får vara upp till :max tecken.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Något i dina startsidesektioner kunde inte sparas. Ladda om sidan och försök igen.", + "Sponsor links can be up to :max characters.": "Sponsorlänkar får vara upp till :max tecken.", + "Sponsor names can be up to :max characters.": "Sponsornamn får vara upp till :max tecken.", + "Text sections can be up to :max characters.": "Textsektioner får vara upp till :max tecken.", + "Video links can be up to :max characters.": "Videolänkar får vara upp till :max tecken.", + "You can add up to :max sections.": "Du kan lägga till upp till :max sektioner.", + "You can add up to :max sponsors.": "Du kan lägga till upp till :max sponsorer.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Instruktioner för offline-betalning kan bara ändras från instrumentpanelen, inte med en API-nyckel.", + "Only members who can manage payments can change the offline payment instructions.": "Endast medlemmar som kan hantera betalningar kan ändra instruktionerna för offline-betalning.", + "Sign in with your email and password instead.": "Logga in med din e-postadress och ditt lösenord i stället.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Den här biljetten är slutsåld, så deltagaren kan inte återaktiveras. Öka antalet och försök igen." } diff --git a/backend/lang/sk.json b/backend/lang/sk.json index 31c8c004ee..426bfc2337 100644 --- a/backend/lang/sk.json +++ b/backend/lang/sk.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Kontajnery Google Tag Manager je možné pridať iba z ovládacieho panela, nie pomocou kľúča API.", "You signed in without single sign-on": "Prihlásili ste sa bez jednotného prihlásenia", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Ak ste to neboli vy, okamžite si zmeňte heslo a znova nastavte dvojfaktorové overenie s novou overovacou aplikáciou.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Tento používateľ vlastní účet, ktorý vyžaduje jednotné prihlásenie. Dvojfaktorové overenie je jeho záložné prihlásenie, preto ho najprv požiadajte, aby vypol povinné jednotné prihlásenie." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Tento používateľ vlastní účet, ktorý vyžaduje jednotné prihlásenie. Dvojfaktorové overenie je jeho záložné prihlásenie, preto ho najprv požiadajte, aby vypol povinné jednotné prihlásenie.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": "Na adresu :newAdminEmail bola odoslaná pozvánka na správcovský prístup k vášmu účtu :appName. Prístup získa po jej prijatí. Správcovia môžu spravovať všetky podujatia, tím, platby a nastavenia účtu.", + "Enter the client secret again when you change the identity provider details.": "Pri zmene údajov poskytovateľa identity zadajte client secret znova.", + "If you did not expect this, remove the invitation and contact support.": "Ak ste to nečakali, odstráňte pozvánku a kontaktujte podporu.", + "A gallery can have up to :max photos.": "Galéria môže obsahovať najviac :max fotografií.", + "An FAQ section can have up to :max questions.": "Sekcia Časté otázky môže obsahovať najviac :max otázok.", + "Every FAQ needs a question.": "Každá položka v sekcii Časté otázky musí mať otázku.", + "Every sponsor needs a logo.": "Každý sponzor musí mať logo.", + "FAQ answers can be up to :max characters.": "Odpovede v sekcii Časté otázky môžu mať najviac :max znakov.", + "FAQ questions can be up to :max characters.": "Otázky v sekcii Časté otázky môžu mať najviac :max znakov.", + "Section headings can be up to :max characters.": "Nadpisy sekcií môžu mať najviac :max znakov.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Niečo v sekciách vašej domovskej stránky sa nepodarilo uložiť. Obnovte stránku a skúste to znova.", + "Sponsor links can be up to :max characters.": "Odkazy sponzorov môžu mať najviac :max znakov.", + "Sponsor names can be up to :max characters.": "Názvy sponzorov môžu mať najviac :max znakov.", + "Text sections can be up to :max characters.": "Textové sekcie môžu mať najviac :max znakov.", + "Video links can be up to :max characters.": "Odkazy na video môžu mať najviac :max znakov.", + "You can add up to :max sections.": "Môžete pridať najviac :max sekcií.", + "You can add up to :max sponsors.": "Môžete pridať najviac :max sponzorov.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Pokyny pre offline platbu je možné zmeniť iba z ovládacieho panela, nie pomocou kľúča API.", + "Only members who can manage payments can change the offline payment instructions.": "Pokyny pre offline platbu môžu meniť iba členovia, ktorí môžu spravovať platby.", + "Sign in with your email and password instead.": "Namiesto toho sa prihláste e-mailom a heslom.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Tento lístok je vypredaný, preto účastníka nemožno znova aktivovať. Zvýšte jeho množstvo a skúste to znova." } diff --git a/backend/lang/tr.json b/backend/lang/tr.json index 1dfa9cf2f4..643536d85c 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -1192,5 +1192,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Google Etiket Yöneticisi kapsayıcıları yalnızca panelden eklenebilir, API anahtarıyla eklenemez.", "You signed in without single sign-on": "Çoklu oturum açma olmadan giriş yaptınız", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Bu siz değilseniz, parolanızı hemen değiştirin ve iki faktörlü kimlik doğrulamayı yeni bir doğrulayıcı uygulamayla yeniden kurun.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Bu kullanıcı çoklu oturum açmayı zorunlu tutan bir hesabın sahibi. İki faktörlü kimlik doğrulama onun yedek giriş yöntemidir, bu yüzden önce çoklu oturum açma zorunluluğunu kapatmasını isteyin." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Bu kullanıcı çoklu oturum açmayı zorunlu tutan bir hesabın sahibi. İki faktörlü kimlik doğrulama onun yedek giriş yöntemidir, bu yüzden önce çoklu oturum açma zorunluluğunu kapatmasını isteyin.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail, :appName hesabınıza yönetici olarak davet edildi. Daveti kabul ettiğinde erişim kazanacak. Yöneticiler tüm etkinlikleri, ekibi, ödemeleri ve hesap ayarlarını yönetebilir.", + "Enter the client secret again when you change the identity provider details.": "Kimlik sağlayıcı bilgilerini değiştirdiğinizde istemci sırrını yeniden girin.", + "If you did not expect this, remove the invitation and contact support.": "Bunu beklemiyorsanız daveti kaldırın ve destekle iletişime geçin.", + "A gallery can have up to :max photos.": "Bir galeride en fazla :max fotoğraf olabilir.", + "An FAQ section can have up to :max questions.": "Bir SSS bölümünde en fazla :max soru olabilir.", + "Every FAQ needs a question.": "Her SSS öğesinin bir sorusu olmalıdır.", + "Every sponsor needs a logo.": "Her sponsorun bir logosu olmalıdır.", + "FAQ answers can be up to :max characters.": "SSS yanıtları en fazla :max karakter olabilir.", + "FAQ questions can be up to :max characters.": "SSS soruları en fazla :max karakter olabilir.", + "Section headings can be up to :max characters.": "Bölüm başlıkları en fazla :max karakter olabilir.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Ana sayfa bölümlerinizdeki bir şey kaydedilemedi. Sayfayı yeniden yükleyip tekrar deneyin.", + "Sponsor links can be up to :max characters.": "Sponsor bağlantıları en fazla :max karakter olabilir.", + "Sponsor names can be up to :max characters.": "Sponsor adları en fazla :max karakter olabilir.", + "Text sections can be up to :max characters.": "Metin bölümleri en fazla :max karakter olabilir.", + "Video links can be up to :max characters.": "Video bağlantıları en fazla :max karakter olabilir.", + "You can add up to :max sections.": "En fazla :max bölüm ekleyebilirsiniz.", + "You can add up to :max sponsors.": "En fazla :max sponsor ekleyebilirsiniz.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Çevrimdışı ödeme talimatları yalnızca panelden değiştirilebilir, API anahtarıyla değiştirilemez.", + "Only members who can manage payments can change the offline payment instructions.": "Çevrimdışı ödeme talimatlarını yalnızca ödemeleri yönetebilen üyeler değiştirebilir.", + "Sign in with your email and password instead.": "Bunun yerine e-posta adresiniz ve şifrenizle giriş yapın.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Bu bilet tükendi, bu nedenle katılımcı yeniden etkinleştirilemez. Biletin miktarını artırın ve tekrar deneyin." } diff --git a/backend/lang/vi.json b/backend/lang/vi.json index 4370679082..ba318541ee 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Chỉ có thể thêm vùng chứa Google Tag Manager từ bảng điều khiển, không thể thêm bằng khóa API.", "You signed in without single sign-on": "Bạn đã đăng nhập mà không dùng đăng nhập một lần", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "Nếu đó không phải là bạn, hãy đổi mật khẩu ngay và thiết lập lại xác thực hai yếu tố bằng một ứng dụng xác thực mới.", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Người dùng này sở hữu một tài khoản bắt buộc đăng nhập một lần. Xác thực hai yếu tố là cách đăng nhập dự phòng của họ, vì vậy hãy yêu cầu họ tắt yêu cầu đăng nhập một lần trước." + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "Người dùng này sở hữu một tài khoản bắt buộc đăng nhập một lần. Xác thực hai yếu tố là cách đăng nhập dự phòng của họ, vì vậy hãy yêu cầu họ tắt yêu cầu đăng nhập một lần trước.", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail đã được mời làm quản trị viên tài khoản :appName của bạn. Họ sẽ có quyền truy cập sau khi chấp nhận lời mời. Quản trị viên có thể quản lý mọi sự kiện, nhóm, thanh toán và cài đặt tài khoản.", + "Enter the client secret again when you change the identity provider details.": "Nhập lại khóa bí mật của ứng dụng khách khi bạn thay đổi thông tin nhà cung cấp danh tính.", + "If you did not expect this, remove the invitation and contact support.": "Nếu bạn không mong đợi điều này, hãy gỡ lời mời và liên hệ bộ phận hỗ trợ.", + "A gallery can have up to :max photos.": "Thư viện ảnh có thể chứa tối đa :max ảnh.", + "An FAQ section can have up to :max questions.": "Mục Câu hỏi thường gặp có thể có tối đa :max câu hỏi.", + "Every FAQ needs a question.": "Mỗi câu hỏi thường gặp đều cần có nội dung câu hỏi.", + "Every sponsor needs a logo.": "Mỗi nhà tài trợ đều cần có logo.", + "FAQ answers can be up to :max characters.": "Câu trả lời trong mục Câu hỏi thường gặp có thể dài tối đa :max ký tự.", + "FAQ questions can be up to :max characters.": "Câu hỏi trong mục Câu hỏi thường gặp có thể dài tối đa :max ký tự.", + "Section headings can be up to :max characters.": "Tiêu đề mục có thể dài tối đa :max ký tự.", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "Không thể lưu một số nội dung trong các mục trang chủ của bạn. Hãy tải lại trang và thử lại.", + "Sponsor links can be up to :max characters.": "Liên kết nhà tài trợ có thể dài tối đa :max ký tự.", + "Sponsor names can be up to :max characters.": "Tên nhà tài trợ có thể dài tối đa :max ký tự.", + "Text sections can be up to :max characters.": "Mục văn bản có thể dài tối đa :max ký tự.", + "Video links can be up to :max characters.": "Liên kết video có thể dài tối đa :max ký tự.", + "You can add up to :max sections.": "Bạn có thể thêm tối đa :max mục.", + "You can add up to :max sponsors.": "Bạn có thể thêm tối đa :max nhà tài trợ.", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Chỉ có thể thay đổi hướng dẫn thanh toán offline trong bảng điều khiển, không thể thay đổi bằng khóa API.", + "Only members who can manage payments can change the offline payment instructions.": "Chỉ những thành viên có quyền quản lý thanh toán mới có thể thay đổi hướng dẫn thanh toán offline.", + "Sign in with your email and password instead.": "Thay vào đó, hãy đăng nhập bằng email và mật khẩu.", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Vé này đã bán hết nên không thể kích hoạt lại người tham dự này. Hãy tăng số lượng vé và thử lại." } diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 6c3b7f4e0b..754b9ca4b1 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Google 跟踪代码管理器容器只能在控制台中添加,不能通过 API 密钥添加。", "You signed in without single sign-on": "您在未使用单点登录的情况下登录了", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "如果不是您本人操作,请立即更改密码,并使用新的身份验证器应用重新设置双重身份验证。", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "该用户拥有一个强制使用单点登录的账户。双重身份验证是其备用登录方式,请先让其停止强制使用单点登录。" + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "该用户拥有一个强制使用单点登录的账户。双重身份验证是其备用登录方式,请先让其停止强制使用单点登录。", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail 已被邀请成为您 :appName 账户的管理员。对方接受邀请后即可获得访问权限。管理员可以管理所有活动、团队、付款和账户设置。", + "Enter the client secret again when you change the identity provider details.": "更改身份提供商信息时,请重新输入客户端密钥。", + "If you did not expect this, remove the invitation and contact support.": "如果这不在您的预期之中,请移除该邀请并联系支持团队。", + "A gallery can have up to :max photos.": "图库最多可包含 :max 张照片。", + "An FAQ section can have up to :max questions.": "常见问题版块最多可包含 :max 个问题。", + "Every FAQ needs a question.": "每条常见问题都需要填写问题。", + "Every sponsor needs a logo.": "每个赞助商都需要一个标志。", + "FAQ answers can be up to :max characters.": "常见问题的回答最多可包含 :max 个字符。", + "FAQ questions can be up to :max characters.": "常见问题的问题最多可包含 :max 个字符。", + "Section headings can be up to :max characters.": "版块标题最多可包含 :max 个字符。", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "主页版块中的部分内容无法保存。请重新加载页面后重试。", + "Sponsor links can be up to :max characters.": "赞助商链接最多可包含 :max 个字符。", + "Sponsor names can be up to :max characters.": "赞助商名称最多可包含 :max 个字符。", + "Text sections can be up to :max characters.": "文本版块最多可包含 :max 个字符。", + "Video links can be up to :max characters.": "视频链接最多可包含 :max 个字符。", + "You can add up to :max sections.": "您最多可以添加 :max 个版块。", + "You can add up to :max sponsors.": "您最多可以添加 :max 个赞助商。", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "线下付款说明只能在控制台中更改,不能通过 API 密钥更改。", + "Only members who can manage payments can change the offline payment instructions.": "只有可以管理付款的成员才能更改线下付款说明。", + "Sign in with your email and password instead.": "请改用您的电子邮件和密码登录。", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "此门票已售罄,因此无法重新激活该参与者。请增加其数量后重试。" } diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json index 1d93cd6012..2fd650703c 100644 --- a/backend/lang/zh-hk.json +++ b/backend/lang/zh-hk.json @@ -1177,5 +1177,26 @@ "Google Tag Manager containers can only be added from the dashboard, not with an API key.": "Google 代碼管理工具容器只能在控制台中新增,不能透過 API 金鑰新增。", "You signed in without single sign-on": "您在未使用單一登入的情況下登入了", "If this wasn't you, change your password and set up two-factor authentication again with a new authenticator straight away.": "如果不是您本人操作,請立即更改密碼,並使用新的驗證器應用程式重新設定雙重驗證。", - "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "此用戶擁有一個強制使用單一登入的帳戶。雙重驗證是其後備登入方式,請先讓其停止強制使用單一登入。" + "This user owns an account that requires single sign-on. Two-factor authentication is their backup sign-in, so ask them to stop requiring single sign-on first.": "此用戶擁有一個強制使用單一登入的帳戶。雙重驗證是其後備登入方式,請先讓其停止強制使用單一登入。", + ":newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.": ":newAdminEmail 已獲邀成為您 :appName 帳戶的管理員。對方接受邀請後即可取得存取權限。管理員可以管理所有活動、團隊、付款及帳戶設定。", + "Enter the client secret again when you change the identity provider details.": "更改身份提供者詳細資料時,請重新輸入客戶端密鑰。", + "If you did not expect this, remove the invitation and contact support.": "如果這並非您預期的操作,請移除該邀請並聯絡支援團隊。", + "A gallery can have up to :max photos.": "相簿最多可包含 :max 張相片。", + "An FAQ section can have up to :max questions.": "常見問題區塊最多可包含 :max 條問題。", + "Every FAQ needs a question.": "每項常見問題都必須填寫問題。", + "Every sponsor needs a logo.": "每個贊助商都必須有標誌。", + "FAQ answers can be up to :max characters.": "常見問題的答案最多可有 :max 個字元。", + "FAQ questions can be up to :max characters.": "常見問題的問題最多可有 :max 個字元。", + "Section headings can be up to :max characters.": "區塊標題最多可有 :max 個字元。", + "Something in your homepage sections couldn't be saved. Reload the page and try again.": "首頁區塊中有部分內容無法儲存。請重新載入頁面後再試。", + "Sponsor links can be up to :max characters.": "贊助商連結最多可有 :max 個字元。", + "Sponsor names can be up to :max characters.": "贊助商名稱最多可有 :max 個字元。", + "Text sections can be up to :max characters.": "文字區塊最多可有 :max 個字元。", + "Video links can be up to :max characters.": "影片連結最多可有 :max 個字元。", + "You can add up to :max sections.": "您最多可新增 :max 個區塊。", + "You can add up to :max sponsors.": "您最多可新增 :max 個贊助商。", + "Offline payment instructions can only be changed from the dashboard, not with an API key.": "線下付款說明只能在控制台中更改,不能透過 API 金鑰更改。", + "Only members who can manage payments can change the offline payment instructions.": "只有可以管理付款的成員才能更改線下付款說明。", + "Sign in with your email and password instead.": "請改用電郵和密碼登入。", + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "此門票已售罄,因此無法重新啟用該參加者。請增加門票數量後再試。" } diff --git a/backend/resources/api-reference/openapi.json b/backend/resources/api-reference/openapi.json index 2e4bc4b444..e512573b03 100644 --- a/backend/resources/api-reference/openapi.json +++ b/backend/resources/api-reference/openapi.json @@ -1427,7 +1427,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -1483,7 +1483,9 @@ "/events/{eventId}/attendees/{attendeePublicId}/check_in": { "post": { "operationId": "attendees.checkInAttendeeAction", + "description": "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}`.", "summary": "Check In Attendee", + "deprecated": true, "tags": [ "Attendees" ], @@ -1546,7 +1548,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -2224,7 +2226,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -3525,7 +3527,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -3691,7 +3693,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -3775,7 +3777,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -3864,7 +3866,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -3953,7 +3955,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -4047,7 +4049,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -4124,7 +4126,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -4174,7 +4176,7 @@ "properties": { "data": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -4343,7 +4345,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -4497,7 +4499,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -4574,7 +4576,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -4896,7 +4898,7 @@ ], "minItems": 3, "maxItems": 3, - "additionalItems": false + "items": false }, "slots": { "type": "array", @@ -4948,7 +4950,7 @@ ], "minItems": 2, "maxItems": 2, - "additionalItems": false + "items": false }, "url": { "type": "string", @@ -5222,7 +5224,7 @@ ], "minItems": 1, "maxItems": 1, - "additionalItems": false + "items": false }, { "type": "array", @@ -5252,7 +5254,7 @@ ], "minItems": 1, "maxItems": 1, - "additionalItems": false + "items": false } ] } @@ -5480,7 +5482,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -5592,7 +5594,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -5669,7 +5671,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -5991,7 +5993,7 @@ ], "minItems": 3, "maxItems": 3, - "additionalItems": false + "items": false }, "slots": { "type": "array", @@ -6043,7 +6045,7 @@ ], "minItems": 2, "maxItems": 2, - "additionalItems": false + "items": false }, "url": { "type": "string", @@ -8751,7 +8753,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -9140,7 +9142,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -9199,7 +9201,7 @@ }, "/public/events/{eventId}": { "get": { - "operationId": "hiEvents.actions.events.getEventPublicAction_0", + "operationId": "events.getEventPublicAction.getPublicEventsEventId", "summary": "Get Event (public)", "tags": [ "Events" @@ -9501,7 +9503,7 @@ }, "/public/events/{eventId}/products": { "get": { - "operationId": "hiEvents.actions.events.getEventPublicAction_0", + "operationId": "events.getEventPublicAction.getPublicEventsEventIdProducts", "summary": "Get Event (public)", "tags": [ "Events" @@ -10749,7 +10751,7 @@ "application/json": { "schema": { "type": "object", - "additionalProperties": [] + "additionalProperties": {} } } } @@ -10857,7 +10859,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -12140,7 +12142,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -12163,7 +12165,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -12280,7 +12282,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -12303,7 +12305,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -12326,7 +12328,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -12427,7 +12429,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -12522,7 +12524,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -12633,7 +12635,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -13602,7 +13604,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -13703,7 +13705,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -13815,7 +13817,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -14231,7 +14233,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -14443,7 +14445,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -14581,7 +14583,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -14742,7 +14744,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -14765,7 +14767,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -14862,7 +14864,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -14885,7 +14887,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -14908,7 +14910,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -15308,7 +15310,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -15480,7 +15482,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -17466,7 +17468,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -17909,7 +17911,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -18172,7 +18174,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -19050,11 +19052,30 @@ "/events/{eventId}/questions": { "post": { "operationId": "questions.createQuestionAction", - "description": "⚠️ Cannot generate request documentation: Cannot use \"::class\" on array", "summary": "Create Question", "tags": [ "Questions" ], + "parameters": [ + { + "name": "eventId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertQuestionRequest" + } + } + } + }, "responses": { "201": { "description": "`QuestionResource`", @@ -19201,11 +19222,38 @@ "/events/{eventId}/questions/{questionId}": { "put": { "operationId": "questions.editQuestionAction", - "description": "⚠️ Cannot generate request documentation: Cannot use \"::class\" on array", "summary": "Edit Question", "tags": [ "Questions" ], + "parameters": [ + { + "name": "eventId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "questionId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertQuestionRequest" + } + } + } + }, "responses": { "200": { "description": "`QuestionResource`", @@ -19393,7 +19441,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -19904,7 +19952,7 @@ }, { "type": "array", - "items": [] + "items": {} } ] } @@ -19920,7 +19968,7 @@ "properties": { "data": { "type": "object", - "additionalProperties": [] + "additionalProperties": {} }, "pagination": { "type": "object", @@ -19958,7 +20006,7 @@ "properties": { "data": { "type": "object", - "additionalProperties": [] + "additionalProperties": {} }, "pagination": { "type": "object", @@ -19991,7 +20039,7 @@ }, { "type": "array", - "items": [] + "items": {} } ] } @@ -20296,7 +20344,7 @@ "application/json": { "schema": { "type": "array", - "items": [] + "items": {} } } } @@ -20804,7 +20852,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -21072,7 +21120,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -21160,7 +21208,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -21273,7 +21321,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -21289,12 +21337,9 @@ }, "errors": { "type": "array", - "items": { - "type": "string" - }, + "items": false, "minItems": 0, - "maxItems": 0, - "additionalItems": false + "maxItems": 0 }, "requires_relabel_confirmation": { "type": "boolean" @@ -21632,7 +21677,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -21822,7 +21867,7 @@ }, "skipped": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -22417,7 +22462,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -22440,7 +22485,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -22544,7 +22589,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -22567,7 +22612,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -22674,7 +22719,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -22697,7 +22742,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -22802,7 +22847,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -22825,7 +22870,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -22848,7 +22893,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -23070,7 +23115,7 @@ } } }, - "/accounts/{accountId}/taxes-and-fees/{taxId}": { + "/accounts/{accountId}/taxes-and-fees/{taxOrFeeId}": { "put": { "operationId": "taxesAndFees.editTaxOrFeeAction", "summary": "Edit Tax Or Fee", @@ -23087,7 +23132,7 @@ } }, { - "name": "taxId", + "name": "taxOrFeeId", "in": "path", "required": true, "schema": { @@ -23167,9 +23212,7 @@ } } } - } - }, - "/accounts/{accountId}/taxes-and-fees/{taxOrFeeId}": { + }, "delete": { "operationId": "taxesAndFees.deleteTaxOrFeeAction", "summary": "Delete Tax Or Fee", @@ -23332,7 +23375,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -23631,7 +23674,7 @@ }, "products": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -23752,7 +23795,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -23775,7 +23818,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -23854,7 +23897,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -23877,7 +23920,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -23982,7 +24025,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -24077,7 +24120,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -24100,7 +24143,7 @@ }, "errors": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -24769,12 +24812,12 @@ } } }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, "401": { "$ref": "#/components/responses/AuthenticationException" }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, "403": { "description": "The authenticated user is not allowed to perform this action.", "content": { @@ -24868,12 +24911,12 @@ } } }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, "401": { "$ref": "#/components/responses/AuthenticationException" }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, "403": { "description": "The authenticated user is not allowed to perform this action.", "content": { @@ -26078,7 +26121,7 @@ }, "question_answers": { "type": "array", - "items": [] + "items": {} }, "email": { "type": [ @@ -26691,15 +26734,15 @@ }, "by_tender": { "type": "array", - "items": [] + "items": {} }, "by_operator": { "type": "array", - "items": [] + "items": {} }, "by_day": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -27321,12 +27364,9 @@ }, { "type": "array", - "items": { - "type": "string" - }, + "items": false, "minItems": 0, - "maxItems": 0, - "additionalItems": false + "maxItems": 0 } ] }, @@ -27369,11 +27409,11 @@ }, "per_product": { "type": "array", - "items": [] + "items": {} }, "recent_check_ins": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -28506,7 +28546,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -28793,7 +28833,7 @@ }, "allocations": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -28815,48 +28855,23 @@ "products_sold": { "type": "integer" }, - "": { - "anyOf": [ - { - "type": "object", - "properties": { - "total_gross_sales": { - "type": "number" - }, - "total_tax": { - "type": "number" - }, - "total_fee": { - "type": "number" - }, - "total_refunded": { - "type": "number" - } - }, - "required": [ - "total_gross_sales", - "total_tax", - "total_fee", - "total_refunded" - ] - }, - { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 0, - "maxItems": 0, - "additionalItems": false - } - ] + "total_gross_sales": { + "type": "number" + }, + "total_tax": { + "type": "number" + }, + "total_fee": { + "type": "number" + }, + "total_refunded": { + "type": "number" } }, "required": [ "orders_created", "attendees_registered", - "products_sold", - null + "products_sold" ] }, "created_at": { @@ -29031,7 +29046,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -29092,7 +29107,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -29324,7 +29339,7 @@ }, "attributes": { "type": "array", - "items": [] + "items": {} }, "images": { "type": [ @@ -29422,7 +29437,7 @@ }, "band_products": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -29450,7 +29465,7 @@ }, "band_products": { "type": "array", - "items": [] + "items": {} }, "prevent_orphan_seats": { "type": "boolean" @@ -29615,7 +29630,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -29633,7 +29648,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -29724,7 +29739,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -29741,7 +29756,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -29940,7 +29955,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -29983,7 +29998,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -30032,7 +30047,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -30046,7 +30061,7 @@ "anyOf": [ { "type": "array", - "items": [] + "items": {} }, { "type": "null" @@ -30480,11 +30495,11 @@ "null" ], "enum": [ - "-1", - "1", - "2", - "3", - "4", + -1, + 1, + 2, + 3, + 4, null ] }, @@ -30519,7 +30534,7 @@ }, "address": { "type": "object", - "additionalProperties": [] + "additionalProperties": {} }, "latitude": { "type": [ @@ -30867,6 +30882,12 @@ "type": "null" } ] + }, + "schedule_days": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -30893,7 +30914,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -30946,7 +30967,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -31207,6 +31228,7 @@ "enum": [ "seating", "box_office", + "enterprise_sso", "white_label" ] } @@ -31263,7 +31285,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -31286,7 +31308,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -31486,7 +31508,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -31603,7 +31625,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -31690,7 +31712,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -31827,7 +31849,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -32037,7 +32059,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -32087,7 +32109,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -32501,7 +32523,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -32518,7 +32540,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -32532,7 +32554,7 @@ "anyOf": [ { "type": "array", - "items": [] + "items": {} }, { "type": "null" @@ -32577,7 +32599,7 @@ }, "tracking_pixels": { "type": "array", - "items": [] + "items": {} }, "first_day_of_week": { "type": "integer" @@ -32632,7 +32654,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -32649,7 +32671,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -32666,7 +32688,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -32719,7 +32741,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -32800,7 +32822,7 @@ }, "all_organizers_currencies": { "type": "array", - "items": [] + "items": {} } }, "required": [ @@ -32973,7 +32995,7 @@ "properties": { "data": { "type": "object", - "additionalProperties": [] + "additionalProperties": {} }, "pagination": { "type": "object", @@ -33906,6 +33928,12 @@ "null" ] }, + "price_before_discount_including_taxes_and_fees": { + "type": [ + "number", + "null" + ] + }, "is_discounted": { "type": "boolean" }, @@ -33970,6 +33998,7 @@ "sale_end_date", "price_including_taxes_and_fees", "price_before_discount", + "price_before_discount_including_taxes_and_fees", "is_discounted", "tax_total", "fee_total", @@ -34300,7 +34329,7 @@ "array", "null" ], - "items": [] + "items": {} }, "addons": { "type": "array", @@ -34485,7 +34514,7 @@ "array", "null" ], - "items": [] + "items": {} } }, "required": [ @@ -34544,7 +34573,7 @@ }, { "type": "array", - "items": [] + "items": {} } ] }, @@ -34657,7 +34686,7 @@ }, { "type": "array", - "items": [] + "items": {} } ] }, @@ -34694,7 +34723,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -34794,7 +34823,7 @@ "anyOf": [ { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -34827,7 +34856,7 @@ "array", "null" ], - "items": [] + "items": {} }, "attendee_id": { "type": [ @@ -34900,7 +34929,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -34970,7 +34999,7 @@ }, { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -38082,6 +38111,71 @@ ], "title": "UpsertProfileRequest" }, + "UpsertQuestionRequest": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 10000 + }, + "type": { + "type": "string", + "enum": [ + "ADDRESS", + "PHONE", + "SINGLE_LINE_TEXT", + "MULTI_LINE_TEXT", + "CHECKBOX", + "RADIO", + "DROPDOWN", + "MULTI_SELECT_DROPDOWN", + "DATE" + ] + }, + "product_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "belongs_to": { + "type": "string", + "enum": [ + "PRODUCT", + "ORDER" + ] + }, + "options": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "required": { + "type": "boolean" + }, + "is_hidden": { + "type": "boolean" + } + }, + "required": [ + "title", + "type", + "belongs_to", + "required", + "is_hidden" + ], + "title": "UpsertQuestionRequest" + }, "UpsertScheduleItemRequest": { "type": "object", "properties": { @@ -38563,7 +38657,7 @@ "anyOf": [ { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -38616,7 +38710,7 @@ "anyOf": [ { "type": "array", - "items": [] + "items": {} }, { "type": "string" @@ -38692,7 +38786,8 @@ "type": "integer" }, "settings": { - "type": "string" + "type": "object", + "additionalProperties": {} }, "created_at": { "type": [ @@ -38716,7 +38811,10 @@ "type": "integer" }, "revenue": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "number" + } } }, "required": [ diff --git a/backend/resources/views/emails/account/admin-access-granted.blade.php b/backend/resources/views/emails/account/admin-access-granted.blade.php index d14efb614b..25cd8f2ad8 100644 --- a/backend/resources/views/emails/account/admin-access-granted.blade.php +++ b/backend/resources/views/emails/account/admin-access-granted.blade.php @@ -1,11 +1,16 @@ @php /** @var string $newAdminEmail */ @endphp @php /** @var string|null $grantedByEmail */ @endphp +@php /** @var bool $isInvitation */ @endphp @php /** @var string $teamUrl */ @endphp {{ __('Hi') }}, +@if($isInvitation) +{{ __(':newAdminEmail was invited as an admin of your :appName account. They get access once they accept. Admins can manage every event, the team, payments and account settings.', ['newAdminEmail' => $newAdminEmail, 'appName' => config('app.name')]) }} +@else {{ __(':newAdminEmail now has admin access to your :appName account. Admins can manage every event, the team, payments and account settings.', ['newAdminEmail' => $newAdminEmail, 'appName' => config('app.name')]) }} +@endif @if($grantedByEmail) {{ __('This change was made by :grantedByEmail.', ['grantedByEmail' => $grantedByEmail]) }} @@ -17,7 +22,11 @@ {{ __('Review your team') }} +@if($isInvitation) +{{ __('If you did not expect this, remove the invitation and contact support.') }} +@else {{ __('If you did not expect this, remove their access and contact support.') }} +@endif {{ __('Thanks,') }} diff --git a/backend/resources/views/emails/orders/attendee-ticket.blade.php b/backend/resources/views/emails/orders/attendee-ticket.blade.php index 7e36d73d90..6f345b07ce 100644 --- a/backend/resources/views/emails/orders/attendee-ticket.blade.php +++ b/backend/resources/views/emails/orders/attendee-ticket.blade.php @@ -3,7 +3,7 @@ @php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp @php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp @php /** @var \HiEvents\DomainObjects\OrderDomainObject $order */ @endphp -@php /** @var array $tickets */ @endphp +@php /** @var array $tickets */ @endphp @php /** @var \HiEvents\Services\Domain\Lineup\DTO\EmailLineupHighlightsDTO|null $lineup */ @endphp @php /** @see \HiEvents\Mail\Attendee\AttendeeTicketMail */ @endphp @@ -37,8 +37,8 @@ $occurrence = $ticket['occurrence']; $venueName = $ticket['venueName']; $addressString = $ticket['addressString']; - $displayStart = $occurrence?->getStartDate() ?? $event->getStartDate(); - $displayEnd = $occurrence?->getEndDate() ?? $event->getEndDate(); + $displayStart = $ticket['startDate']; + $displayEnd = $ticket['endDate']; $startFormatted = $formatDateTime($displayStart); $endFormatted = null; diff --git a/backend/resources/views/vendor/mail/html/panel.blade.php b/backend/resources/views/vendor/mail/html/panel.blade.php index ae97c13b9a..2975a60a02 100644 --- a/backend/resources/views/vendor/mail/html/panel.blade.php +++ b/backend/resources/views/vendor/mail/html/panel.blade.php @@ -1,14 +1,14 @@ - - - + + + 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) }}
diff --git a/backend/routes/api.php b/backend/routes/api.php index ce67f330e7..dc8b64fa8a 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -963,7 +963,7 @@ function (Router $router): void { // Box office $router->get('/box-offices/{box_office_short_id}', GetBoxOfficePublicAction::class) - ->middleware('throttle:60,1,box-office-read'); + ->middleware('throttle:box-office-read'); $router->post('/box-offices/{box_office_short_id}/sessions', CreateBoxOfficeSessionPublicAction::class) ->middleware('throttle:box-office-session') ->middleware('ee.licensed:box_office'); diff --git a/backend/tests/Feature/Auth/ResetPasswordErrorsTest.php b/backend/tests/Feature/Auth/ResetPasswordErrorsTest.php new file mode 100644 index 0000000000..990fe8d34f --- /dev/null +++ b/backend/tests/Feature/Auth/ResetPasswordErrorsTest.php @@ -0,0 +1,75 @@ + 1], [ + 'id' => 1, + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => ['percentage' => 1.5, 'fixed' => 0], + ]); + } + + public function test_reusing_the_current_password_is_reported_on_the_password_field(): void + { + $user = User::factory()->password(self::PASSWORD)->withAccount()->create(); + + $this->postJson('/auth/reset-password/'.$this->resetToken($user), [ + 'password' => self::PASSWORD, + 'password_confirmation' => self::PASSWORD, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['password' => 'New password must be different from the old password.']) + ->assertJsonMissingValidationErrors('current_password'); + } + + public function test_resetting_while_password_sign_in_is_turned_off_is_reported_on_the_password_field(): void + { + config()->set('sso.password_login_disabled', true); + $user = User::factory()->password(self::PASSWORD)->withAccount()->create(); + + $this->postJson('/auth/reset-password/'.$this->resetToken($user), [ + 'password' => 'NewCorrectHorse123!', + 'password_confirmation' => 'NewCorrectHorse123!', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('password') + ->assertJsonMissingValidationErrors('current_password'); + + $this->assertTrue(Hash::check(self::PASSWORD, $user->fresh()->password)); + } + + private function resetToken(User $user): string + { + $token = Str::random(64); + + DB::table('password_reset_tokens')->insert([ + 'email' => $user->email, + 'token' => $token, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $token; + } +} diff --git a/backend/tests/Feature/Auth/ResetPasswordTest.php b/backend/tests/Feature/Auth/ResetPasswordTest.php index c44256548b..0e97a8d80d 100644 --- a/backend/tests/Feature/Auth/ResetPasswordTest.php +++ b/backend/tests/Feature/Auth/ResetPasswordTest.php @@ -145,6 +145,6 @@ public function test_reset_password_with_old_password(): void 'password_confirmation' => $password, ]); - $response2->assertJsonValidationErrors('current_password'); + $response2->assertJsonValidationErrors('password'); } } diff --git a/backend/tests/Feature/Auth/Sso/EnterpriseSsoLoginTest.php b/backend/tests/Feature/Auth/Sso/EnterpriseSsoLoginTest.php index 347226b7f4..c57cd1f908 100644 --- a/backend/tests/Feature/Auth/Sso/EnterpriseSsoLoginTest.php +++ b/backend/tests/Feature/Auth/Sso/EnterpriseSsoLoginTest.php @@ -331,11 +331,73 @@ public function test_an_account_owner_is_never_linked_by_email_through_a_connect $this->exchangeOidcCode($this->startConnectionFlow($connection), $this->connectionClaims('rogue-idp', 'founder@acme.test')) ->assertConflict() - ->assertJsonPath('error_code', 'SSO_LINK_REQUIRES_LOGIN'); + ->assertJsonPath('error_code', 'SSO_LINK_REQUIRES_LOGIN') + ->assertJsonPath('message', 'Sign in with your email and password instead.') + ->assertJsonPath('context', []); + + $this->assertDatabaseMissing('user_identities', ['user_id' => $owner->id]); + } + + public function test_the_owner_of_the_connections_account_is_told_to_connect_it_from_security_settings(): void + { + [$owner, $accountId] = $this->createAccountOwner(); + $owner->update(['email' => 'founder@acme.test']); + $connection = $this->createConnection($accountId, ['jit_provisioning_enabled' => true]); + $this->addDomain($accountId, 'acme.test'); + + $this->exchangeOidcCode($this->startConnectionFlow($connection), $this->connectionClaims('okta-founder', 'founder@acme.test')) + ->assertConflict() + ->assertJsonPath('error_code', 'SSO_LINK_REQUIRES_LOGIN') + ->assertJsonPath('message', 'Sign in with your email and password, then connect this sign-in option from your security settings.') + ->assertJsonPath('context.email', 'founder@acme.test'); + + $this->exchangeOidcCode($this->startConnectionFlow($connection), $this->connectionClaims('okta-founder', 'founder@acme.test', ['email_verified' => false])) + ->assertConflict() + ->assertJsonPath('error_code', 'SSO_LINK_REQUIRES_LOGIN') + ->assertJsonPath('context', []); $this->assertDatabaseMissing('user_identities', ['user_id' => $owner->id]); } + public function test_a_superadmin_never_signs_in_or_confirms_their_identity_through_a_connection(): void + { + [, $accountId] = $this->createAccountOwner(); + $connection = $this->createConnection($accountId); + $this->addDomain($accountId, 'acme.test'); + $linked = $this->addAccountUser($accountId, Role::SUPERADMIN); + $linked->update(['email' => 'linked-staff@acme.test']); + UserIdentity::create([ + 'user_id' => $linked->id, + 'account_sso_connection_id' => $connection->id, + 'provider_key' => 'connection-'.$connection->id, + 'issuer' => self::CONNECTION_ISSUER, + 'subject' => 'okta-staff', + ]); + $unlinked = $this->addAccountUser($accountId, Role::SUPERADMIN); + $unlinked->update(['email' => 'staff@acme.test']); + + $this->exchangeOidcCode($this->startConnectionFlow($connection), $this->connectionClaims('okta-staff', 'linked-staff@acme.test')) + ->assertConflict() + ->assertJsonPath('error_code', 'SSO_LINK_REQUIRES_LOGIN') + ->assertJsonPath('context', []) + ->assertJsonMissingPath('data.token'); + + $this->exchangeOidcCode($this->startConnectionFlow($connection), $this->connectionClaims('okta-other', 'staff@acme.test')) + ->assertConflict() + ->assertJsonPath('error_code', 'SSO_LINK_REQUIRES_LOGIN') + ->assertJsonPath('context', []); + $this->assertDatabaseMissing('user_identities', ['user_id' => $unlinked->id]); + + $token = $this->tokenFor($linked, $accountId); + $flow = $this->flowFrom($this->postJson('/users/me/sso-identities/connection-'.$connection->id.'/reauth', [], $this->bearer($token)) + ->assertOk() + ->json('data')); + + $this->exchangeOidcCode($flow, $this->connectionClaims('okta-staff', 'linked-staff@acme.test', ['auth_time' => time()]), '/users/me/sso-identities/exchange', $this->bearer($token)) + ->assertConflict() + ->assertJsonPath('error_code', 'SSO_REAUTH_FAILED'); + } + public function test_a_member_of_other_accounts_cannot_confirm_their_identity_through_a_connection(): void { [, $accountId] = $this->createAccountOwner(); diff --git a/backend/tests/Feature/Auth/Sso/SsoConnectionLinkTest.php b/backend/tests/Feature/Auth/Sso/SsoConnectionLinkTest.php new file mode 100644 index 0000000000..3259fb28e2 --- /dev/null +++ b/backend/tests/Feature/Auth/Sso/SsoConnectionLinkTest.php @@ -0,0 +1,378 @@ +setFeatureFlagDefault(FeatureFlag::ENTERPRISE_SSO, true); + + Mail::fake(); + AccountConfiguration::firstOrCreate(['id' => 1], [ + 'id' => 1, + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => ['percentage' => 1.5, 'fixed' => 0], + ]); + config()->set('app.saas_mode_enabled', false); + $this->fakeConnectionIssuer(); + $this->ownerSecret = (new Google2FA)->generateSecretKey(32); + } + + public function test_the_owner_links_the_connection_and_signs_in_through_it_without_an_emergency_sign_in_email(): void + { + [$owner, $accountId, $connection] = $this->enforcingAccount(); + $this->member($accountId, 'admin@acme.test', enforced: true, role: Role::ADMIN); + + $this->linkConnection($owner, $accountId, $connection, 'okta-owner') + ->assertOk() + ->assertJsonPath('data.status', 'LINKED') + ->assertJsonPath('data.provider_name', 'Okta') + ->assertJsonPath('data.return_path', '/settings/security'); + + $this->assertDatabaseHas('user_identities', [ + 'user_id' => $owner->id, + 'provider_key' => 'connection-'.$connection->id, + 'account_sso_connection_id' => $connection->id, + 'issuer' => self::CONNECTION_ISSUER, + 'subject' => 'okta-owner', + ]); + Mail::assertQueued(SignInMethodChangedMail::class, fn (SignInMethodChangedMail $mail) => $mail->hasTo($owner->email)); + + $flow = $this->startConnectionFlow($connection); + $challenge = $this->exchangeOidcCode($flow, $this->connectionClaims('okta-owner', 'it-lead@elsewhere.test')) + ->assertOk() + ->assertJsonPath('data.status', 'TWO_FACTOR_REQUIRED') + ->json('data.two_factor_challenge_token'); + + $token = $this->postJson('/auth/login/two-factor', [ + 'challenge_token' => $challenge, + 'code' => (new Google2FA)->getCurrentOtp($this->ownerSecret), + 'sso_binding' => $flow['binding'], + ])->assertOk()->json('token'); + + $payload = $this->decodeToken($token); + $this->assertSame($accountId, $payload['account_id']); + $this->assertSame($connection->id, $payload['sso_connection_id']); + Mail::assertNotQueued(SsoBreakGlassSignInMail::class); + $this->getJson('/users/me', $this->bearer($token))->assertOk(); + } + + public function test_a_linked_owner_is_never_enforced_and_their_password_sign_in_still_emails_every_admin(): void + { + [$owner, $accountId, $connection] = $this->enforcingAccount(); + $admin = $this->member($accountId, 'admin@acme.test', enforced: true, role: Role::ADMIN); + $this->linkConnection($owner, $accountId, $connection, 'okta-owner')->assertOk(); + + $members = collect($this->getJson("/accounts/$accountId/sso/enforcement", $this->headersFor($owner, $accountId)) + ->assertOk() + ->json('data.members'))->keyBy('email'); + $this->assertTrue($members[$owner->email]['sso_available']); + $this->assertFalse($members[$owner->email]['sso_enforced']); + + $challenge = $this->postJson('/auth/login', ['email' => $owner->email, 'password' => self::PASSWORD]) + ->assertOk() + ->assertJson(['two_factor_required' => true]) + ->json('two_factor_challenge_token'); + + $this->postJson('/auth/login/two-factor', [ + 'challenge_token' => $challenge, + 'code' => (new Google2FA)->getCurrentOtp($this->ownerSecret), + ])->assertOk()->assertJsonStructure(['token']); + + Mail::assertQueued(SsoBreakGlassSignInMail::class, fn (SsoBreakGlassSignInMail $mail) => $mail->hasTo($admin->email)); + Mail::assertQueued(SsoBreakGlassSignInMail::class, fn (SsoBreakGlassSignInMail $mail) => $mail->hasTo($owner->email)); + } + + public function test_a_member_links_the_connection_without_gaining_access_or_changing_role(): void + { + [, $accountId, $connection] = $this->enforcingAccount(enabled: false); + $member = $this->member($accountId, 'grace@elsewhere.test', role: Role::ORGANIZER); + + $this->linkConnection($member, $accountId, $connection, 'okta-grace', 'grace@acme.test') + ->assertOk() + ->assertJsonPath('data.status', 'LINKED'); + + $memberships = DB::table('account_users')->where('user_id', $member->id)->get(); + $this->assertCount(1, $memberships); + $this->assertSame(Role::ORGANIZER->value, $memberships->first()->role); + $this->assertFalse((bool) $memberships->first()->sso_enforced); + $this->assertSame('grace@elsewhere.test', $member->fresh()->email); + + $token = $this->exchangeOidcCode($this->startConnectionFlow($connection), $this->connectionClaims('okta-grace', 'grace@acme.test')) + ->assertOk() + ->assertJsonPath('data.status', 'AUTHENTICATED') + ->json('data.token'); + $this->assertSame($member->id, (int) $this->decodeToken($token)['sub']); + } + + public function test_someone_outside_the_account_cannot_link_its_connection(): void + { + [, $accountId, $connection] = $this->enforcingAccount(); + $outsider = User::factory()->password(self::PASSWORD)->withAccount()->create(); + $outsiderAccountId = $outsider->accounts()->first()->id; + + $this->postJson('/users/me/sso-identities/connection-'.$connection->id.'/start', ['password' => self::PASSWORD], $this->headersFor($outsider, $outsiderAccountId)) + ->assertNotFound() + ->assertJsonPath('error_code', 'SSO_PROVIDER_NOT_CONFIGURED'); + + $invited = $this->member($accountId, 'invited@acme.test', status: UserStatus::INVITED); + $invited->accounts()->attach($outsiderAccountId, ['role' => Role::ORGANIZER->value, 'status' => UserStatus::ACTIVE->name, 'is_account_owner' => false]); + $this->postJson('/users/me/sso-identities/connection-'.$connection->id.'/start', ['password' => self::PASSWORD], $this->headersFor($invited, $outsiderAccountId)) + ->assertNotFound() + ->assertJsonPath('error_code', 'SSO_PROVIDER_NOT_CONFIGURED'); + + $this->assertDatabaseMissing('user_identities', ['account_sso_connection_id' => $connection->id]); + } + + public function test_a_member_of_another_account_can_link_only_that_accounts_connection(): void + { + [, $accountId, $connection] = $this->enforcingAccount(); + [, $otherAccountId] = $this->createAccountOwner(); + $otherConnection = $this->createConnection($otherAccountId); + $member = $this->member($otherAccountId, 'grace@other.test'); + + $this->postJson('/users/me/sso-identities/connection-'.$connection->id.'/start', ['password' => self::PASSWORD], $this->headersFor($member, $otherAccountId)) + ->assertNotFound() + ->assertJsonPath('error_code', 'SSO_PROVIDER_NOT_CONFIGURED'); + + $this->postJson('/users/me/sso-identities/connection-'.$otherConnection->id.'/start', ['password' => self::PASSWORD], $this->headersFor($member, $otherAccountId)) + ->assertOk(); + } + + public function test_membership_is_checked_again_when_the_link_finishes(): void + { + [, $accountId, $connection] = $this->enforcingAccount(); + $member = $this->member($accountId, 'grace@acme.test'); + $otherAccountId = User::factory()->withAccount()->create()->accounts()->first()->id; + $member->accounts()->attach($otherAccountId, ['role' => Role::ORGANIZER->value, 'status' => UserStatus::ACTIVE->name, 'is_account_owner' => false]); + $headers = $this->headersFor($member, $otherAccountId); + $flow = $this->flowFrom($this->postJson('/users/me/sso-identities/connection-'.$connection->id.'/start', ['password' => self::PASSWORD], $headers) + ->assertOk() + ->json('data')); + + DB::table('account_users')->where(['user_id' => $member->id, 'account_id' => $accountId])->update(['status' => UserStatus::INACTIVE->name]); + + $this->exchangeOidcCode($flow, $this->connectionClaims('okta-grace', 'grace@acme.test'), '/users/me/sso-identities/exchange', $headers) + ->assertNotFound() + ->assertJsonPath('error_code', 'SSO_PROVIDER_NOT_CONFIGURED'); + + $this->assertDatabaseMissing('user_identities', ['user_id' => $member->id]); + } + + public function test_an_identity_that_belongs_to_someone_else_cannot_be_linked(): void + { + [, $accountId, $connection] = $this->enforcingAccount(); + $first = $this->member($accountId, 'first@acme.test'); + $second = $this->member($accountId, 'second@acme.test'); + $this->linkConnection($first, $accountId, $connection, 'okta-shared')->assertOk(); + + $this->linkConnection($second, $accountId, $connection, 'okta-shared') + ->assertConflict() + ->assertJsonPath('error_code', 'SSO_IDENTITY_IN_USE'); + + $this->assertDatabaseMissing('user_identities', ['user_id' => $second->id]); + } + + public function test_a_connection_that_is_turned_off_cannot_be_linked(): void + { + [$owner, $accountId, $connection] = $this->enforcingAccount(enabled: false); + $connection->update(['status' => 'INACTIVE']); + + $this->postJson('/users/me/sso-identities/connection-'.$connection->id.'/start', ['password' => self::PASSWORD], $this->headersFor($owner, $accountId)) + ->assertNotFound() + ->assertJsonPath('error_code', 'SSO_PROVIDER_NOT_CONFIGURED'); + } + + public function test_a_connection_cannot_be_linked_while_impersonating(): void + { + [$owner, $accountId, $connection] = $this->enforcingAccount(enabled: false); + $impersonation = JWTAuth::claims([ + 'account_id' => $accountId, + 'is_impersonating' => true, + 'impersonator_id' => User::factory()->create()->id, + ])->fromUser($owner); + + $this->postJson('/users/me/sso-identities/connection-'.$connection->id.'/start', ['password' => self::PASSWORD], $this->bearer($impersonation)) + ->assertForbidden() + ->assertJsonPath('error_code', 'IMPERSONATION_NOT_ALLOWED'); + } + + public function test_the_owner_unlinks_the_connection_and_can_no_longer_sign_in_through_it(): void + { + [$owner, $accountId, $connection] = $this->enforcingAccount(); + $this->linkConnection($owner, $accountId, $connection, 'okta-owner')->assertOk(); + $identity = UserIdentity::where(['user_id' => $owner->id, 'subject' => 'okta-owner'])->firstOrFail(); + + $this->deleteJson('/users/me/sso-identities/'.$identity->id, ['password' => self::PASSWORD], $this->headersFor($owner, $accountId)) + ->assertOk(); + + $this->assertDatabaseMissing('user_identities', ['id' => $identity->id]); + Mail::assertQueued(SignInMethodChangedMail::class, 2); + + $this->exchangeOidcCode($this->startConnectionFlow($connection), $this->connectionClaims('okta-owner', $owner->email)) + ->assertConflict() + ->assertJsonPath('error_code', 'SSO_LINK_REQUIRES_LOGIN'); + } + + public function test_a_member_who_owns_another_account_is_enforced_once_they_link_the_connection(): void + { + [$owner, $accountId, $connection] = $this->enforcingAccount(); + $alice = $this->member($accountId, 'alice@acme.test', enforced: true); + $aliceAccountId = User::factory()->withAccount()->create()->accounts()->first()->id; + $alice->accounts()->attach($aliceAccountId, [ + 'role' => Role::ADMIN->value, + 'status' => UserStatus::ACTIVE->name, + 'is_account_owner' => true, + ]); + + $this->assertFalse($this->previewFor($owner, $accountId)['alice@acme.test']['sso_available']); + $this->postJson('/auth/login', ['email' => 'alice@acme.test', 'password' => self::PASSWORD, 'account_id' => $accountId])->assertOk(); + + $this->linkConnection($alice, $accountId, $connection, 'okta-alice')->assertOk(); + + $this->assertTrue($this->previewFor($owner, $accountId)['alice@acme.test']['sso_available']); + $this->postJson('/auth/login', ['email' => 'alice@acme.test', 'password' => self::PASSWORD, 'account_id' => $accountId]) + ->assertForbidden() + ->assertJsonPath('error_code', 'SSO_REQUIRED'); + $this->postJson('/auth/login', ['email' => 'alice@acme.test', 'password' => self::PASSWORD, 'account_id' => $aliceAccountId])->assertOk(); + $this->exchangeOidcCode($this->startConnectionFlow($connection), $this->connectionClaims('okta-alice', 'alice@acme.test')) + ->assertOk() + ->assertJsonPath('data.status', 'AUTHENTICATED'); + } + + public function test_sign_in_methods_offer_the_connections_of_accounts_the_user_is_active_in(): void + { + [, $accountId, $connection] = $this->enforcingAccount(enabled: false); + DB::table('accounts')->where('id', $accountId)->update(['name' => 'Acme Events']); + $member = $this->member($accountId, 'grace@acme.test'); + [, $inactiveAccountId] = $this->createAccountOwner(); + $this->createConnection($inactiveAccountId, ['status' => 'INACTIVE', 'issuer' => 'https://inactive.okta.test']); + $member->accounts()->attach($inactiveAccountId, ['role' => Role::ORGANIZER->value, 'status' => UserStatus::ACTIVE->name, 'is_account_owner' => false]); + [, $invitedAccountId] = $this->createAccountOwner(); + $this->createConnection($invitedAccountId, ['issuer' => 'https://invited.okta.test']); + $member->accounts()->attach($invitedAccountId, ['role' => Role::ORGANIZER->value, 'status' => UserStatus::INVITED->name, 'is_account_owner' => false]); + + $this->getJson('/users/me/sso-identities', $this->headersFor($member, $accountId)) + ->assertOk() + ->assertJsonCount(0, 'data') + ->assertJsonCount(1, 'meta.linkable_connections') + ->assertJsonPath('meta.linkable_connections.0', [ + 'key' => 'connection-'.$connection->id, + 'name' => 'Okta', + 'type' => 'OIDC', + 'logo_url' => null, + 'account_name' => 'Acme Events', + ]); + } + + public function test_a_superadmin_cannot_link_a_connection_or_see_it_offered(): void + { + [, $accountId, $connection] = $this->enforcingAccount(enabled: false); + $staff = $this->member($accountId, 'staff@acme.test', role: Role::SUPERADMIN); + + $this->getJson('/users/me/sso-identities', $this->headersFor($staff, $accountId)) + ->assertOk() + ->assertJsonCount(0, 'meta.linkable_connections'); + + $this->postJson('/users/me/sso-identities/connection-'.$connection->id.'/start', ['password' => self::PASSWORD], $this->headersFor($staff, $accountId)) + ->assertNotFound() + ->assertJsonPath('error_code', 'SSO_PROVIDER_NOT_CONFIGURED'); + + $this->assertDatabaseMissing('user_identities', ['user_id' => $staff->id]); + } + + public function test_a_member_made_superadmin_during_a_link_is_refused_when_it_finishes(): void + { + [, $accountId, $connection] = $this->enforcingAccount(); + $member = $this->member($accountId, 'grace@acme.test'); + $headers = $this->headersFor($member, $accountId); + $flow = $this->flowFrom($this->postJson('/users/me/sso-identities/connection-'.$connection->id.'/start', ['password' => self::PASSWORD], $headers) + ->assertOk() + ->json('data')); + + DB::table('account_users')->where('user_id', $member->id)->update(['role' => Role::SUPERADMIN->name]); + + $this->exchangeOidcCode($flow, $this->connectionClaims('okta-grace', 'grace@acme.test'), '/users/me/sso-identities/exchange', $headers) + ->assertNotFound() + ->assertJsonPath('error_code', 'SSO_PROVIDER_NOT_CONFIGURED'); + + $this->assertDatabaseMissing('user_identities', ['user_id' => $member->id]); + } + + private function linkConnection(User $user, int $accountId, AccountSsoConnection $connection, string $subject, ?string $email = null): TestResponse + { + $headers = $this->headersFor($user, $accountId); + $flow = $this->flowFrom($this->postJson('/users/me/sso-identities/connection-'.$connection->id.'/start', [ + 'password' => self::PASSWORD, + 'return_path' => '/settings/security', + ], $headers)->assertOk()->json('data')); + + return $this->exchangeOidcCode($flow, $this->connectionClaims($subject, $email ?? $user->email), '/users/me/sso-identities/exchange', $headers); + } + + private function previewFor(User $owner, int $accountId): array + { + return collect($this->getJson("/accounts/$accountId/sso/enforcement", $this->headersFor($owner, $accountId)) + ->assertOk() + ->json('data.members'))->keyBy('email')->all(); + } + + /** + * @return array{0: User, 1: int, 2: AccountSsoConnection} + */ + private function enforcingAccount(bool $enabled = true): array + { + $owner = User::factory()->password(self::PASSWORD)->withTwoFactor($this->ownerSecret)->withAccount()->create(); + $accountId = $owner->accounts()->first()->id; + $connection = $this->createConnection($accountId, ['enforce_sso' => $enabled]); + $this->addDomain($accountId, 'acme.test'); + + return [$owner, $accountId, $connection]; + } + + private function member(int $accountId, string $email, bool $enforced = false, Role $role = Role::ORGANIZER, UserStatus $status = UserStatus::ACTIVE): User + { + $user = User::factory()->password(self::PASSWORD)->create(['email' => $email]); + $user->accounts()->attach($accountId, [ + 'role' => $role->value, + 'status' => $status->name, + 'is_account_owner' => false, + 'sso_enforced' => $enforced, + ]); + + return $user; + } +} diff --git a/backend/tests/Feature/Auth/Sso/SsoEnforcementTest.php b/backend/tests/Feature/Auth/Sso/SsoEnforcementTest.php index 36b7731a8d..f282a68e55 100644 --- a/backend/tests/Feature/Auth/Sso/SsoEnforcementTest.php +++ b/backend/tests/Feature/Auth/Sso/SsoEnforcementTest.php @@ -10,6 +10,7 @@ use HiEvents\Models\AccountConfiguration; use HiEvents\Models\AccountSsoConnection; use HiEvents\Models\User; +use HiEvents\Models\UserIdentity; use HiEvents\Services\Domain\Auth\TwoFactor\TrustedDeviceService; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; @@ -417,6 +418,175 @@ public function test_the_team_list_shows_how_each_member_signs_in(): void $this->assertSame('PASSWORD', $users[$owner->id]['sso_sign_in']); } + public function test_requiring_sso_leaves_members_the_connection_cannot_sign_in_signed_in_with_their_api_keys(): void + { + [$owner, $accountId] = $this->enforcingAccount(enabled: false); + $alice = $this->member($accountId, 'alice@acme.test'); + $aliceAccountId = $this->giveOwnAccount($alice); + $staff = $this->member($accountId, 'staff@acme.test', role: Role::SUPERADMIN); + $grace = $this->member($accountId, 'grace@acme.test'); + foreach ([$alice, $staff, $grace] as $member) { + $this->createApiKey($member, $accountId, name: 'Here'); + } + $aliceToken = $this->tokenFor($alice, $accountId); + $aliceOwnAccountToken = $this->tokenFor($alice, $aliceAccountId); + $staffToken = $this->tokenFor($staff, $accountId); + + $preview = $this->previewFor($owner, $accountId); + $this->assertFalse($preview['alice@acme.test']['sso_available']); + $this->assertFalse($preview['staff@acme.test']['sso_available']); + $this->assertTrue($preview['grace@acme.test']['sso_available']); + + $this->travel(5)->seconds(); + $this->putJson("/accounts/$accountId/sso/enforcement", ['enabled' => true], $this->bearer($this->tokenFor($owner, $accountId))) + ->assertOk(); + + foreach ([$alice, $staff] as $member) { + $this->assertTrue($this->isEnforced($member, $accountId)); + $this->assertNull($member->fresh()->tokens_valid_after); + $this->assertSame(['Here'], $this->apiKeyNames($member)); + } + $this->getJson('/users/me', $this->bearer($aliceToken))->assertOk(); + $this->getJson('/users/me', $this->bearer($aliceOwnAccountToken))->assertOk(); + $this->getJson('/users/me', $this->bearer($staffToken))->assertOk(); + $this->postJson('/auth/login', ['email' => 'alice@acme.test', 'password' => self::PASSWORD, 'account_id' => $accountId])->assertOk(); + + $this->assertNotNull($grace->fresh()->tokens_valid_after); + $this->assertSame([], $this->apiKeyNames($grace)); + + $signIn = $this->teamSignInFor($owner, $accountId); + $this->assertSame('PASSWORD', $signIn[$alice->id]); + $this->assertSame('PASSWORD', $signIn[$staff->id]); + $this->assertSame('SSO', $signIn[$grace->id]); + } + + public function test_requiring_sso_for_one_member_the_connection_cannot_sign_in_keeps_their_sessions(): void + { + [$owner, $accountId] = $this->enforcingAccount(); + $alice = $this->member($accountId, 'alice@acme.test'); + $this->giveOwnAccount($alice); + $staff = $this->member($accountId, 'staff@acme.test', role: Role::SUPERADMIN); + + foreach ([$alice, $staff] as $member) { + $this->createApiKey($member, $accountId, name: 'Here'); + + $this->putJson("/accounts/$accountId/sso/members/{$this->accountUserId($member, $accountId)}", ['sso_enforced' => true], $this->bearer($this->tokenFor($owner, $accountId))) + ->assertOk(); + + $this->assertTrue($this->isEnforced($member, $accountId)); + $this->assertNull($member->fresh()->tokens_valid_after); + $this->assertSame(['Here'], $this->apiKeyNames($member)); + } + } + + public function test_a_member_who_owns_another_account_moves_to_sso_once_linked_but_a_superadmin_never_does(): void + { + [$owner, $accountId, $connection] = $this->enforcingAccount(); + $alice = $this->member($accountId, 'alice@acme.test', enforced: true); + $this->giveOwnAccount($alice); + $staff = $this->member($accountId, 'staff@acme.test', enforced: true, role: Role::SUPERADMIN); + + $signIn = $this->teamSignInFor($owner, $accountId); + $this->assertSame('PASSWORD', $signIn[$alice->id]); + $this->assertSame('PASSWORD', $signIn[$staff->id]); + + foreach (['alice' => $alice, 'staff' => $staff] as $subject => $member) { + UserIdentity::create([ + 'user_id' => $member->id, + 'account_sso_connection_id' => $connection->id, + 'provider_key' => 'connection-'.$connection->id, + 'issuer' => self::CONNECTION_ISSUER, + 'subject' => 'okta-'.$subject, + ]); + } + + $preview = $this->previewFor($owner, $accountId); + $this->assertTrue($preview['alice@acme.test']['sso_available']); + $this->assertFalse($preview['staff@acme.test']['sso_available']); + + $signIn = $this->teamSignInFor($owner, $accountId); + $this->assertSame('SSO', $signIn[$alice->id]); + $this->assertSame('PASSWORD', $signIn[$staff->id]); + + $this->postJson('/auth/login', ['email' => 'alice@acme.test', 'password' => self::PASSWORD, 'account_id' => $accountId]) + ->assertForbidden() + ->assertJsonPath('error_code', 'SSO_REQUIRED'); + $this->postJson('/auth/login', ['email' => 'staff@acme.test', 'password' => self::PASSWORD, 'account_id' => $accountId]) + ->assertOk(); + } + + public function test_a_personal_google_identity_does_not_count_as_a_way_into_a_workspace_connection(): void + { + $owner = User::factory()->password(self::PASSWORD)->withTwoFactor($this->ownerSecret)->withAccount()->create(); + $accountId = $owner->accounts()->first()->id; + $this->createConnection($accountId, [ + 'type' => 'GOOGLE_WORKSPACE', + 'issuer' => 'https://accounts.google.com', + 'hosted_domain' => 'acme.test', + 'enforce_sso' => true, + ]); + $this->addDomain($accountId, 'acme.test'); + + foreach (['alice@acme.test' => 'alice.personal@gmail.com', 'bob@acme.test' => 'bob@acme.test'] as $email => $googleEmail) { + $member = $this->member($accountId, $email, enforced: true); + $this->giveOwnAccount($member); + UserIdentity::create([ + 'user_id' => $member->id, + 'provider_key' => 'google', + 'issuer' => 'https://accounts.google.com', + 'subject' => 'google-'.$member->id, + 'email' => $googleEmail, + ]); + } + + $preview = $this->previewFor($owner, $accountId); + $this->assertFalse($preview['alice@acme.test']['sso_available']); + $this->assertTrue($preview['bob@acme.test']['sso_available']); + + $this->postJson('/auth/login', ['email' => 'alice@acme.test', 'password' => self::PASSWORD, 'account_id' => $accountId]) + ->assertOk(); + } + + private function giveOwnAccount(User $user): int + { + $ownAccountId = User::factory()->withAccount()->create()->accounts()->first()->id; + $user->accounts()->attach($ownAccountId, [ + 'role' => Role::ADMIN->value, + 'status' => UserStatus::ACTIVE->name, + 'is_account_owner' => true, + ]); + + return $ownAccountId; + } + + /** + * @return array> + */ + private function previewFor(User $owner, int $accountId): array + { + return collect($this->getJson("/accounts/$accountId/sso/enforcement", $this->bearer($this->tokenFor($owner, $accountId))) + ->assertOk() + ->json('data.members'))->keyBy('email')->all(); + } + + /** + * @return array + */ + private function teamSignInFor(User $owner, int $accountId): array + { + return collect($this->getJson('/users', $this->bearer($this->tokenFor($owner, $accountId)))->assertOk()->json('data')) + ->mapWithKeys(fn (array $user) => [$user['id'] => $user['sso_sign_in'] ?? null]) + ->all(); + } + + /** + * @return string[] + */ + private function apiKeyNames(User $user): array + { + return DB::table('personal_access_tokens')->where('tokenable_id', $user->id)->pluck('name')->all(); + } + /** * @return array{0: User, 1: int, 2: AccountSsoConnection} */ diff --git a/backend/tests/Feature/Auth/Sso/SsoEnforcementTwoFactorTest.php b/backend/tests/Feature/Auth/Sso/SsoEnforcementTwoFactorTest.php new file mode 100644 index 0000000000..43bcb9438a --- /dev/null +++ b/backend/tests/Feature/Auth/Sso/SsoEnforcementTwoFactorTest.php @@ -0,0 +1,129 @@ +setFeatureFlagDefault(FeatureFlag::ENTERPRISE_SSO, true); + + Mail::fake(); + AccountConfiguration::firstOrCreate(['id' => 1], [ + 'id' => 1, + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => ['percentage' => 1.5, 'fixed' => 0], + ]); + config()->set('app.saas_mode_enabled', false); + $this->fakeConnectionIssuer(); + $this->secret = (new Google2FA)->generateSecretKey(32); + } + + public function test_an_enforced_member_with_two_factor_is_told_to_use_sso_before_being_asked_for_a_code(): void + { + [, $accountId] = $this->enforcingAccount(); + $member = $this->enforcedMember($accountId, 'grace@acme.test'); + $recoveryCodes = $member->fresh()->two_factor_recovery_codes; + + $this->postJson('/auth/login', ['email' => 'grace@acme.test', 'password' => self::PASSWORD]) + ->assertForbidden() + ->assertJsonPath('error_code', 'SSO_REQUIRED') + ->assertJsonPath('context.email', 'grace@acme.test') + ->assertJsonMissingPath('two_factor_challenge_token'); + + $this->assertSame($recoveryCodes, $member->fresh()->two_factor_recovery_codes); + } + + public function test_an_enforced_member_with_two_factor_still_gets_a_challenge_when_signing_in_through_the_connection(): void + { + [, $accountId, $connection] = $this->enforcingAccount(); + $this->enforcedMember($accountId, 'grace@acme.test'); + $flow = $this->startConnectionFlow($connection); + + $challenge = $this->exchangeOidcCode($flow, $this->connectionClaims('okta-grace', 'grace@acme.test')) + ->assertOk() + ->assertJsonPath('data.status', 'TWO_FACTOR_REQUIRED') + ->json('data.two_factor_challenge_token'); + + $this->postJson('/auth/login/two-factor', [ + 'challenge_token' => $challenge, + 'code' => (new Google2FA)->getCurrentOtp($this->secret), + 'sso_binding' => $flow['binding'], + ])->assertOk()->assertJsonStructure(['token']); + } + + public function test_an_enforced_member_signing_in_to_another_account_still_gets_a_challenge(): void + { + [, $accountId] = $this->enforcingAccount(); + $member = $this->enforcedMember($accountId, 'grace@acme.test'); + $otherAccountId = User::factory()->withAccount()->create()->accounts()->first()->id; + $member->accounts()->attach($otherAccountId, [ + 'role' => Role::ORGANIZER->value, + 'status' => UserStatus::ACTIVE->name, + 'is_account_owner' => false, + ]); + + $this->postJson('/auth/login', ['email' => 'grace@acme.test', 'password' => self::PASSWORD, 'account_id' => $otherAccountId]) + ->assertOk() + ->assertJson(['two_factor_required' => true]); + + $this->postJson('/auth/login', ['email' => 'grace@acme.test', 'password' => self::PASSWORD, 'account_id' => $accountId]) + ->assertForbidden() + ->assertJsonPath('error_code', 'SSO_REQUIRED'); + } + + /** + * @return array{0: User, 1: int, 2: AccountSsoConnection} + */ + private function enforcingAccount(): array + { + $owner = User::factory()->password(self::PASSWORD)->withTwoFactor($this->secret)->withAccount()->create(); + $accountId = $owner->accounts()->first()->id; + $connection = $this->createConnection($accountId, ['enforce_sso' => true]); + $this->addDomain($accountId, 'acme.test'); + + return [$owner, $accountId, $connection]; + } + + private function enforcedMember(int $accountId, string $email): User + { + $user = User::factory() + ->password(self::PASSWORD) + ->withTwoFactor($this->secret, [self::RECOVERY_CODE]) + ->create(['email' => $email]); + $user->accounts()->attach($accountId, [ + 'role' => Role::ORGANIZER->value, + 'status' => UserStatus::ACTIVE->name, + 'is_account_owner' => false, + 'sso_enforced' => true, + ]); + + return $user; + } +} diff --git a/backend/tests/Feature/Auth/Sso/SsoInvitationEnforcementTest.php b/backend/tests/Feature/Auth/Sso/SsoInvitationEnforcementTest.php new file mode 100644 index 0000000000..8bd3d68604 --- /dev/null +++ b/backend/tests/Feature/Auth/Sso/SsoInvitationEnforcementTest.php @@ -0,0 +1,147 @@ +setFeatureFlagDefault(FeatureFlag::ENTERPRISE_SSO, true); + + Mail::fake(); + AccountConfiguration::firstOrCreate(['id' => 1], [ + 'id' => 1, + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => ['percentage' => 1.5, 'fixed' => 0], + ]); + config()->set('app.saas_mode_enabled', false); + $this->fakeConnectionIssuer(); + } + + public function test_the_invitation_says_when_the_account_requires_single_sign_on(): void + { + [$owner, $accountId] = $this->enforcingAccount(); + $enforced = $this->invite($owner, $accountId, 'new.hire@acme.test'); + $contractor = $this->invite($owner, $accountId, 'contractor@agency.test'); + + $this->getJson('/auth/invitation/'.$this->invitationToken($enforced, $accountId)) + ->assertOk() + ->assertJsonPath('data.sso_required', true); + $this->getJson('/auth/invitation/'.$this->invitationToken($contractor, $accountId)) + ->assertOk() + ->assertJsonPath('data.sso_required', false); + } + + public function test_an_invitee_who_must_use_single_sign_on_cannot_accept_with_a_password(): void + { + [$owner, $accountId] = $this->enforcingAccount(); + $invitee = $this->invite($owner, $accountId, 'new.hire@acme.test'); + + $this->postJson('/auth/invitation/'.$this->invitationToken($invitee, $accountId), [ + 'first_name' => 'New', + 'last_name' => 'Hire', + 'password' => self::PASSWORD, + 'password_confirmation' => self::PASSWORD, + 'timezone' => 'Europe/Dublin', + ]) + ->assertForbidden() + ->assertJsonPath('error_code', 'SSO_REQUIRED') + ->assertJsonPath('context.email', 'new.hire@acme.test'); + + $this->assertNull($invitee->fresh()->password); + $this->assertSame(UserStatus::INVITED->name, $this->membershipStatus($invitee, $accountId)); + } + + public function test_an_invitee_outside_the_verified_domains_still_accepts_with_a_password(): void + { + [$owner, $accountId] = $this->enforcingAccount(); + $invitee = $this->invite($owner, $accountId, 'contractor@agency.test'); + + $this->postJson('/auth/invitation/'.$this->invitationToken($invitee, $accountId), [ + 'first_name' => 'Con', + 'last_name' => 'Tractor', + 'password' => self::PASSWORD, + 'password_confirmation' => self::PASSWORD, + 'timezone' => 'Europe/Dublin', + ])->assertNoContent(); + + $this->assertSame(UserStatus::ACTIVE->name, $this->membershipStatus($invitee, $accountId)); + } + + public function test_an_invitee_who_must_use_single_sign_on_joins_by_signing_in_through_the_connection(): void + { + [$owner, $accountId, $connection] = $this->enforcingAccount(); + $invitee = $this->invite($owner, $accountId, 'new.hire@acme.test'); + + $this->exchangeOidcCode($this->startConnectionFlow($connection), $this->connectionClaims('okta-new-hire', 'new.hire@acme.test')) + ->assertOk() + ->assertJsonPath('data.status', 'AUTHENTICATED'); + + $this->assertSame(UserStatus::ACTIVE->name, $this->membershipStatus($invitee, $accountId)); + } + + /** + * @return array{0: User, 1: int, 2: AccountSsoConnection} + */ + private function enforcingAccount(): array + { + $owner = User::factory()->password(self::PASSWORD)->withTwoFactor((new Google2FA)->generateSecretKey(32))->withAccount()->create(); + $accountId = $owner->accounts()->first()->id; + $connection = $this->createConnection($accountId, ['enforce_sso' => true]); + $this->addDomain($accountId, 'acme.test'); + + return [$owner, $accountId, $connection]; + } + + private function invite(User $owner, int $accountId, string $email): User + { + $this->postJson('/users', [ + 'first_name' => 'Invited', + 'last_name' => 'Person', + 'email' => $email, + 'role' => 'ORGANIZER', + ], $this->bearer($this->tokenFor($owner, $accountId)))->assertCreated(); + + return User::where('email', $email)->firstOrFail(); + } + + private function invitationToken(User $user, int $accountId): string + { + return app(EncryptedPayloadService::class)->encryptPayload([ + 'user_id' => $user->id, + 'account_id' => $accountId, + ]); + } + + private function membershipStatus(User $user, int $accountId): string + { + return DB::table('account_users') + ->where(['user_id' => $user->id, 'account_id' => $accountId]) + ->value('status'); + } +} diff --git a/backend/tests/Feature/Console/SsoStatusCommandTest.php b/backend/tests/Feature/Console/SsoStatusCommandTest.php new file mode 100644 index 0000000000..bc5649aaff --- /dev/null +++ b/backend/tests/Feature/Console/SsoStatusCommandTest.php @@ -0,0 +1,55 @@ +set('sso.providers.google.client_id', null); + config()->set('sso.providers.microsoft.client_id', null); + config()->set('sso.password_login_disabled', true); + User::factory()->create(); + } + + public function test_it_warns_that_users_are_locked_out_when_the_oidc_provider_does_not_trust_emails(): void + { + $this->fakeOidcProvider(trustEmail: false); + + $this->artisan('sso:status') + ->expectsOutputToContain('Example SSO: none, because SSO_OIDC_TRUST_EMAIL is off.') + ->expectsOutputToContain('Example SSO can\'t match these users to their accounts because SSO_OIDC_TRUST_EMAIL is off, so they can\'t sign in with it.') + ->doesntExpectOutputToContain('once their provider email is verified') + ->assertSuccessful(); + } + + public function test_it_explains_which_emails_a_trusted_oidc_provider_matches(): void + { + $this->fakeOidcProvider(trustEmail: true); + + $this->artisan('sso:status') + ->expectsOutputToContain('Example SSO: emails it marks as verified, because SSO_OIDC_TRUST_EMAIL is on.') + ->doesntExpectOutputToContain('SSO_OIDC_TRUST_EMAIL is off') + ->assertSuccessful(); + } + + public function test_it_stays_quiet_about_matching_while_password_sign_in_is_on(): void + { + config()->set('sso.password_login_disabled', false); + $this->fakeOidcProvider(trustEmail: false); + + $this->artisan('sso:status') + ->doesntExpectOutputToContain('SSO_OIDC_TRUST_EMAIL') + ->assertSuccessful(); + } +} diff --git a/backend/tests/Feature/Enterprise/Sso/AccountSsoConnectionSecretTest.php b/backend/tests/Feature/Enterprise/Sso/AccountSsoConnectionSecretTest.php new file mode 100644 index 0000000000..4547b58c5a --- /dev/null +++ b/backend/tests/Feature/Enterprise/Sso/AccountSsoConnectionSecretTest.php @@ -0,0 +1,148 @@ +setFeatureFlagDefault(FeatureFlag::ENTERPRISE_SSO, true); + + Mail::fake(); + AccountConfiguration::firstOrCreate(['id' => 1], [ + 'id' => 1, + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => ['percentage' => 1.5, 'fixed' => 0], + ]); + $this->fakeConnectionIssuer(); + $this->fakeConnectionIssuer(self::ROGUE_ISSUER); + [$this->owner, $this->accountId] = $this->createAccountOwner(); + $this->connection = $this->createConnection($this->accountId); + } + + public function test_pointing_the_connection_at_another_issuer_needs_the_client_secret_again(): void + { + $member = $this->addAccountUser($this->accountId); + UserIdentity::create([ + 'user_id' => $member->id, + 'account_sso_connection_id' => $this->connection->id, + 'provider_key' => 'connection-'.$this->connection->id, + 'issuer' => self::CONNECTION_ISSUER, + 'subject' => 'okta-member', + ]); + + $this->putJson($this->url(), $this->payload(['issuer' => self::ROGUE_ISSUER]), $this->ownerHeaders()) + ->assertUnprocessable() + ->assertJsonValidationErrors('client_secret'); + + $this->assertConnectionUnchanged(); + $this->assertNull($member->fresh()->tokens_valid_after); + Http::assertNotSent(fn (Request $request) => str_starts_with($request->url(), self::ROGUE_ISSUER)); + + $this->putJson($this->url(), $this->payload(['issuer' => self::ROGUE_ISSUER, 'client_secret' => 'rogue-secret']), $this->ownerHeaders()) + ->assertOk() + ->assertJsonPath('data.issuer', self::ROGUE_ISSUER) + ->assertJsonPath('data.status', 'INACTIVE'); + $this->assertSame('rogue-secret', $this->connection->fresh()->client_secret); + } + + public function test_switching_the_provider_type_needs_the_client_secret_again(): void + { + $this->putJson($this->url(), $this->payload(['type' => 'OIDC', 'issuer' => self::ROGUE_ISSUER]), $this->ownerHeaders()) + ->assertUnprocessable() + ->assertJsonValidationErrors('client_secret'); + + $this->assertConnectionUnchanged(); + } + + public function test_changing_the_client_id_needs_the_client_secret_again(): void + { + $this->putJson($this->url(), $this->payload(['client_id' => 'new-client']), $this->ownerHeaders()) + ->assertUnprocessable() + ->assertJsonValidationErrors('client_secret'); + + $this->assertConnectionUnchanged(); + + $this->putJson($this->url(), $this->payload(['client_id' => 'new-client', 'client_secret' => 'new-secret']), $this->ownerHeaders()) + ->assertOk() + ->assertJsonPath('data.client_id', 'new-client') + ->assertJsonPath('data.status', 'INACTIVE'); + $this->assertSame('new-secret', $this->connection->fresh()->client_secret); + } + + public function test_a_settings_change_keeps_the_saved_secret(): void + { + $this->putJson($this->url(), $this->payload(['session_ttl_minutes' => 120]), $this->ownerHeaders()) + ->assertOk() + ->assertJsonPath('data.status', 'ACTIVE') + ->assertJsonPath('data.session_ttl_minutes', 120); + + $this->assertSame('connection-secret', $this->connection->fresh()->client_secret); + } + + private function assertConnectionUnchanged(): void + { + $connection = $this->connection->fresh(); + + $this->assertSame('OKTA', $connection->type); + $this->assertSame(self::CONNECTION_ISSUER, $connection->issuer); + $this->assertSame(self::CONNECTION_CLIENT_ID, $connection->client_id); + $this->assertSame('connection-secret', $connection->client_secret); + $this->assertSame('ACTIVE', $connection->status); + $this->assertNotNull($connection->last_tested_at); + } + + private function payload(array $overrides = []): array + { + return [ + 'type' => 'OKTA', + 'issuer' => self::CONNECTION_ISSUER, + 'client_id' => self::CONNECTION_CLIENT_ID, + 'client_secret' => null, + 'session_ttl_minutes' => 720, + 'jit_provisioning_enabled' => false, + 'jit_default_role' => 'ORGANIZER', + ...$overrides, + ]; + } + + private function url(): string + { + return "/accounts/$this->accountId/sso/connection"; + } + + private function ownerHeaders(): array + { + return $this->bearer($this->tokenFor($this->owner, $this->accountId)); + } +} diff --git a/backend/tests/Feature/Enterprise/Sso/EnterpriseSsoLicenceLapseTest.php b/backend/tests/Feature/Enterprise/Sso/EnterpriseSsoLicenceLapseTest.php new file mode 100644 index 0000000000..2e0a86f921 --- /dev/null +++ b/backend/tests/Feature/Enterprise/Sso/EnterpriseSsoLicenceLapseTest.php @@ -0,0 +1,128 @@ +setFeatureFlagDefault(FeatureFlag::ENTERPRISE_SSO, true); + + Mail::fake(); + AccountConfiguration::firstOrCreate(['id' => 1], [ + 'id' => 1, + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => ['percentage' => 1.5, 'fixed' => 0], + ]); + config()->set('app.saas_mode_enabled', false); + $this->fakeConnectionIssuer(); + + $this->owner = User::factory()->password(self::PASSWORD)->withTwoFactor((new Google2FA)->generateSecretKey(32))->withAccount()->create(); + $this->accountId = $this->owner->accounts()->first()->id; + $this->connection = $this->createConnection($this->accountId, ['enforce_sso' => true]); + $this->addDomain($this->accountId, 'acme.test'); + } + + public static function lapsedLicences(): array + { + return [ + 'expired licence' => ['expired'], + 'no licence key' => ['missing'], + ]; + } + + #[DataProvider('lapsedLicences')] + public function test_members_keep_signing_in_through_the_connection_and_enforcement_still_applies(string $licence): void + { + $this->member('grace@acme.test'); + $this->lapse($licence); + + $this->postJson('/auth/sso/discover', ['email' => 'grace@acme.test']) + ->assertOk() + ->assertJsonPath('data.key', 'connection-'.$this->connection->id); + + $token = $this->exchangeOidcCode($this->startConnectionFlow($this->connection), $this->connectionClaims('okta-grace', 'grace@acme.test')) + ->assertOk() + ->assertJsonPath('data.status', 'AUTHENTICATED') + ->json('data.token'); + $this->getJson('/users/me', $this->bearer($token)) + ->assertOk() + ->assertJsonPath('data.email', 'grace@acme.test'); + + $this->postJson('/auth/login', ['email' => 'grace@acme.test', 'password' => self::PASSWORD]) + ->assertForbidden() + ->assertJsonPath('error_code', 'SSO_REQUIRED'); + } + + #[DataProvider('lapsedLicences')] + public function test_an_admin_can_exempt_a_member_but_not_require_them_again(string $licence): void + { + $member = $this->member('grace@acme.test'); + $accountUserId = $this->accountUserId($member, $this->accountId); + $this->lapse($licence); + + $this->putJson("/accounts/$this->accountId/sso/members/$accountUserId", ['sso_enforced' => false], $this->ownerHeaders()) + ->assertOk(); + $this->postJson('/auth/login', ['email' => 'grace@acme.test', 'password' => self::PASSWORD])->assertOk(); + + $this->putJson("/accounts/$this->accountId/sso/members/$accountUserId", ['sso_enforced' => true], $this->ownerHeaders()) + ->assertForbidden() + ->assertJsonPath('error_code', 'FEATURE_UNAVAILABLE'); + } + + private function lapse(string $licence): void + { + $licence === 'expired' ? $this->useLicence(expiresAt: '2020-01-01') : $this->useLicenceKey(null); + } + + private function member(string $email): User + { + $user = User::factory()->password(self::PASSWORD)->create(['email' => $email]); + $user->accounts()->attach($this->accountId, [ + 'role' => Role::ORGANIZER->value, + 'status' => UserStatus::ACTIVE->name, + 'is_account_owner' => false, + 'sso_enforced' => true, + ]); + + return $user; + } + + private function ownerHeaders(): array + { + return $this->bearer($this->tokenFor($this->owner, $this->accountId)); + } +} diff --git a/backend/tests/Feature/Http/Actions/Accounts/Sso/AccountSsoSettingsTest.php b/backend/tests/Feature/Http/Actions/Accounts/Sso/AccountSsoSettingsTest.php index 8ebdbce4e7..138ec800ed 100644 --- a/backend/tests/Feature/Http/Actions/Accounts/Sso/AccountSsoSettingsTest.php +++ b/backend/tests/Feature/Http/Actions/Accounts/Sso/AccountSsoSettingsTest.php @@ -175,6 +175,11 @@ public function test_secrets_rotate_in_place_and_changing_the_provider_requires_ $connection->update(['enforce_sso' => false]); $this->putJson($this->url('/connection'), [...$this->oktaPayload(), 'client_id' => 'new-client', 'client_secret' => null], $this->ownerHeaders()) + ->assertUnprocessable() + ->assertJsonValidationErrors('client_secret'); + $this->assertSame('rotated-secret', $connection->fresh()->client_secret); + + $this->putJson($this->url('/connection'), [...$this->oktaPayload(), 'client_id' => 'new-client', 'client_secret' => 'new-secret'], $this->ownerHeaders()) ->assertOk() ->assertJsonPath('data.status', 'INACTIVE') ->assertJsonPath('data.last_tested_at', null); diff --git a/backend/tests/Feature/Http/Actions/Admin/Users/ImpersonationTokenTest.php b/backend/tests/Feature/Http/Actions/Admin/Users/ImpersonationTokenTest.php new file mode 100644 index 0000000000..2ead1b850d --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Admin/Users/ImpersonationTokenTest.php @@ -0,0 +1,157 @@ + 1], [ + 'id' => 1, + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => ['percentage' => 1.5, 'fixed' => 0], + ]); + } + + public function test_only_a_superadmin_can_start_impersonating(): void + { + [$owner, $accountId] = $this->createAccountOwner(); + $target = $this->addAccountUser($accountId); + + $this->postJson("/admin/impersonate/{$target->id}", ['account_id' => $accountId], $this->headersFor($owner, $accountId)) + ->assertForbidden(); + } + + public function test_a_superadmin_cannot_impersonate_another_superadmin(): void + { + [$superadmin, $superadminAccountId] = $this->superadmin(); + [, $targetAccountId] = $this->createAccountOwner(); + $target = $this->addAccountUser($targetAccountId, Role::SUPERADMIN); + + $this->postJson("/admin/impersonate/{$target->id}", ['account_id' => $targetAccountId], $this->headersFor($superadmin, $superadminAccountId)) + ->assertForbidden(); + } + + public function test_a_superadmin_cannot_impersonate_someone_outside_the_account(): void + { + [$superadmin, $superadminAccountId] = $this->superadmin(); + [, $targetAccountId] = $this->createAccountOwner(); + [$outsider] = $this->createAccountOwner(); + + $this->postJson("/admin/impersonate/{$outsider->id}", ['account_id' => $targetAccountId], $this->headersFor($superadmin, $superadminAccountId)) + ->assertForbidden(); + } + + public function test_the_impersonation_token_carries_its_claims_through_a_refresh(): void + { + [$superadmin, $superadminAccountId] = $this->superadmin(); + [, $targetAccountId] = $this->createAccountOwner(); + $target = $this->addAccountUser($targetAccountId); + + $token = $this->postJson("/admin/impersonate/{$target->id}", ['account_id' => $targetAccountId], $this->headersFor($superadmin, $superadminAccountId)) + ->assertOk() + ->json('token'); + + $claims = $this->decodeToken($token); + $this->assertSame($target->id, (int) $claims['sub']); + $this->assertSame($targetAccountId, $claims['account_id']); + $this->assertSame('IMPERSONATION', $claims['auth_method']); + $this->assertSame($superadmin->id, $claims['impersonator_id']); + $this->assertTrue($claims['is_impersonating']); + + $this->travel(2)->seconds(); + $refreshed = $this->postJson('/auth/refresh', [], $this->bearer($token))->assertOk()->json('token'); + + $refreshedClaims = $this->decodeToken($refreshed); + $this->assertSame($targetAccountId, $refreshedClaims['account_id']); + $this->assertSame('IMPERSONATION', $refreshedClaims['auth_method']); + $this->assertSame($superadmin->id, $refreshedClaims['impersonator_id']); + $this->assertTrue($refreshedClaims['is_impersonating']); + $this->assertSame($claims['iat'], $refreshedClaims['iat']); + } + + public function test_stopping_impersonation_retires_the_impersonation_token(): void + { + [$superadmin, $superadminAccountId] = $this->superadmin(); + [, $targetAccountId] = $this->createAccountOwner(); + $target = $this->addAccountUser($targetAccountId); + + $impersonationToken = $this->postJson("/admin/impersonate/{$target->id}", ['account_id' => $targetAccountId], $this->headersFor($superadmin, $superadminAccountId)) + ->assertOk() + ->json('token'); + + $returnedToken = $this->postJson('/admin/stop-impersonation', [], $this->bearer($impersonationToken)) + ->assertOk() + ->json('token'); + + $this->getJson('/users/me', $this->bearer($impersonationToken))->assertUnauthorized(); + $this->postJson('/admin/stop-impersonation', [], $this->bearer($impersonationToken))->assertUnauthorized(); + $this->getJson('/users/me', $this->bearer($returnedToken)) + ->assertOk() + ->assertJsonPath('data.id', $superadmin->id); + } + + public function test_an_impersonation_token_works_for_a_member_who_must_use_single_sign_on(): void + { + $this->setFeatureFlagDefault(FeatureFlag::ENTERPRISE_SSO, true); + $this->fakeConnectionIssuer(); + [$superadmin, $superadminAccountId] = $this->superadmin(); + [, $targetAccountId] = $this->createAccountOwner(); + $this->createConnection($targetAccountId, ['enforce_sso' => true]); + $this->addDomain($targetAccountId, 'acme.test'); + $member = User::factory()->create(['email' => 'grace@acme.test']); + $member->accounts()->attach($targetAccountId, [ + 'role' => Role::ORGANIZER->value, + 'status' => UserStatus::ACTIVE->name, + 'is_account_owner' => false, + 'sso_enforced' => true, + ]); + + $this->getJson('/users/me', $this->headersFor($member, $targetAccountId)) + ->assertUnauthorized() + ->assertJsonPath('error_code', 'SSO_REQUIRED'); + + $token = $this->postJson("/admin/impersonate/{$member->id}", ['account_id' => $targetAccountId], $this->headersFor($superadmin, $superadminAccountId)) + ->assertOk() + ->json('token'); + + $this->getJson('/users/me', $this->bearer($token)) + ->assertOk() + ->assertJsonPath('data.id', $member->id); + } + + /** + * @return array{0: User, 1: int} + */ + private function superadmin(): array + { + [$superadmin, $accountId] = $this->createAccountOwner(); + DB::table('account_users') + ->where(['user_id' => $superadmin->id, 'account_id' => $accountId]) + ->update(['role' => Role::SUPERADMIN->value]); + + return [$superadmin, $accountId]; + } +} diff --git a/backend/tests/Feature/Http/Actions/Attendees/LegacyCheckInAttendeeActionTest.php b/backend/tests/Feature/Http/Actions/Attendees/LegacyCheckInAttendeeActionTest.php new file mode 100644 index 0000000000..8ac23a7852 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Attendees/LegacyCheckInAttendeeActionTest.php @@ -0,0 +1,131 @@ +owner, $this->accountId] = $this->createAccountOwner(); + $this->eventId = $this->createEvent($this->accountId, $this->createOrganizer($this->accountId)); + } + + public function test_checking_out_an_attendee_who_was_never_checked_in_is_a_conflict(): void + { + $publicId = $this->createAttendee(); + + $this->checkIn($publicId, 'check_out') + ->assertStatus(409) + ->assertJsonPath('message', 'This attendee is not checked in'); + } + + public function test_an_attendee_can_be_checked_in_and_out_once(): void + { + $publicId = $this->createAttendee(); + + $this->checkIn($publicId, 'check_in')->assertOk()->assertJsonPath('data.public_id', $publicId); + $this->assertNotNull(DB::table('attendees')->where('public_id', $publicId)->value('checked_in_at')); + + $this->checkIn($publicId, 'check_in')->assertStatus(409); + + $this->checkIn($publicId, 'check_out')->assertOk(); + $this->assertNull(DB::table('attendees')->where('public_id', $publicId)->value('checked_in_at')); + + $this->checkIn($publicId, 'check_out')->assertStatus(409); + } + + public function test_an_inactive_attendee_cannot_be_checked_in(): void + { + $publicId = $this->createAttendee(AttendeeStatus::CANCELLED->name); + + $this->checkIn($publicId, 'check_in')->assertStatus(409); + } + + public function test_the_api_reference_marks_the_endpoint_deprecated_and_points_to_check_in_lists(): void + { + $operation = json_decode( + file_get_contents(base_path('resources/api-reference/openapi.json')), + true, + flags: JSON_THROW_ON_ERROR, + )['paths'][self::OPERATION_PATH]['post']; + + $this->assertTrue($operation['deprecated'] ?? false); + $this->assertStringContainsString('GET /events/{eventId}/check-in-lists', $operation['description']); + $this->assertStringContainsString('POST /public/check-in-lists/{checkInListShortId}/check-ins', $operation['description']); + } + + private function checkIn(string $attendeePublicId, string $action) + { + return $this->postJson( + "/events/{$this->eventId}/attendees/$attendeePublicId/check_in", + ['action' => $action], + $this->headersFor($this->owner, $this->accountId), + ); + } + + private function createAttendee(string $status = AttendeeStatus::ACTIVE->name): string + { + $productId = DB::table('products')->insertGetId([ + 'title' => 'General Admission', + 'event_id' => $this->eventId, + 'order' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $priceId = DB::table('product_prices')->insertGetId([ + 'product_id' => $productId, + 'price' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $orderId = DB::table('orders')->insertGetId([ + 'short_id' => 'ord_'.uniqid(), + 'event_id' => $this->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(), + ]); + $publicId = 'ATT_'.uniqid(); + DB::table('attendees')->insert([ + 'short_id' => 'att_'.uniqid(), + 'email' => 'attendee@example.test', + 'first_name' => 'Ada', + 'last_name' => 'Attendee', + 'order_id' => $orderId, + 'product_id' => $productId, + 'product_price_id' => $priceId, + 'event_id' => $this->eventId, + 'public_id' => $publicId, + 'status' => $status, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $publicId; + } +} diff --git a/backend/tests/Feature/Http/Actions/Attendees/PartialEditAttendeeActionTest.php b/backend/tests/Feature/Http/Actions/Attendees/PartialEditAttendeeActionTest.php new file mode 100644 index 0000000000..a1944e2a46 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Attendees/PartialEditAttendeeActionTest.php @@ -0,0 +1,94 @@ +insertAccountAndOrganizer(); + $this->authToken = JWTAuth::claims(['account_id' => $this->accountId])->fromUser(User::find($this->userId)); + + $this->eventId = $this->insertEvent(EventType::SINGLE->name); + $this->occurrenceId = $this->insertOccurrence(capacity: 1, usedCapacity: 1); + $this->productId = $this->insertProduct(); + $this->priceId = $this->insertPrice($this->productId, null, ProductQuantityAppliesTo::EVENT->name, quantitySold: 1); + DB::table('event_statistics')->insert([ + 'event_id' => $this->eventId, + 'products_sold' => 1, + 'attendees_registered' => 1, + 'orders_created' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->sellTickets($this->productId, $this->priceId, $this->occurrenceId, 1); + + $orderId = $this->sellTickets($this->productId, $this->priceId, $this->occurrenceId, 1, attendeeStatus: AttendeeStatus::CANCELLED->name); + $this->attendeeId = (int) DB::table('attendees')->where('order_id', $orderId)->value('id'); + } + + public function test_activating_a_cancelled_attendee_on_a_full_event_returns_a_validation_error_against_status(): void + { + $this->activate() + ->assertStatus(422) + ->assertJsonPath('errors.status.0', __('This event is sold out')); + + $this->assertDatabaseHas('attendees', ['id' => $this->attendeeId, 'status' => AttendeeStatus::CANCELLED->name]); + $this->assertSame(1, (int) DB::table('event_occurrences')->where('id', $this->occurrenceId)->value('used_capacity')); + $this->assertSame(1, (int) DB::table('product_prices')->where('id', $this->priceId)->value('quantity_sold')); + } + + public function test_activating_a_cancelled_attendee_once_the_event_has_room_again(): void + { + DB::table('event_occurrences')->where('id', $this->occurrenceId)->update(['capacity' => 2]); + + $this->activate() + ->assertOk() + ->assertJsonPath('data.status', AttendeeStatus::ACTIVE->name); + + $this->assertSame(2, (int) DB::table('event_occurrences')->where('id', $this->occurrenceId)->value('used_capacity')); + $this->assertSame(2, (int) DB::table('product_prices')->where('id', $this->priceId)->value('quantity_sold')); + $this->assertSame(2, (int) DB::table('event_statistics')->where('event_id', $this->eventId)->value('attendees_registered')); + } + + private function activate(): TestResponse + { + return $this->withHeader('Authorization', 'Bearer '.$this->authToken) + ->patchJson(sprintf('/events/%d/attendees/%d', $this->eventId, $this->attendeeId), [ + 'status' => AttendeeStatus::ACTIVE->name, + ]); + } +} diff --git a/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionValidationMessagesTest.php b/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionValidationMessagesTest.php new file mode 100644 index 0000000000..6b05a35280 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionValidationMessagesTest.php @@ -0,0 +1,155 @@ + [['id' => 'faq', 'type' => 'FAQ', 'faqs' => $faqs]]; + $sponsors = fn (array $sponsors) => [['id' => 'thanks', 'type' => 'SPONSORS', 'sponsors' => $sponsors]]; + $sponsor = fn (array $overrides) => $sponsors([[...['image_id' => 1, 'name' => 'Acme', 'url' => 'https://acme.test'], ...$overrides]]); + + return [ + 'too many sections' => [ + array_map(fn (int $index) => ['id' => "text_$index", 'type' => 'TEXT'], range(1, 41)), + 'homepage_sections', + 'You can add up to 40 sections.', + ], + 'sections not a list' => ['nope', 'homepage_sections', self::INVALID_SECTION], + 'malformed section id' => [[['id' => 'bad id!', 'type' => 'TEXT']], 'homepage_sections.0.id', self::INVALID_SECTION], + 'missing section id' => [[['type' => 'TEXT']], 'homepage_sections.0.id', self::INVALID_SECTION], + 'unsupported section type' => [[['id' => 'x', 'type' => 'EVENTS']], 'homepage_sections.0.type', self::INVALID_SECTION], + 'non-boolean enabled' => [[['id' => 'x', 'type' => 'TEXT', 'enabled' => 'sometimes']], 'homepage_sections.0.enabled', self::INVALID_SECTION], + 'heading too long' => [ + [['id' => 'x', 'type' => 'TEXT', 'heading' => str_repeat('h', 101)]], + 'homepage_sections.0.heading', + 'Section headings can be up to 100 characters.', + ], + 'text too long' => [ + [['id' => 'x', 'type' => 'TEXT', 'body' => str_repeat('b', 50001)]], + 'homepage_sections.0.body', + 'Text sections can be up to 50000 characters.', + ], + 'too many questions' => [ + $faq(array_fill(0, 51, ['question' => 'Parking?', 'answer' => '

Yes

'])), + 'homepage_sections.0.faqs', + 'An FAQ section can have up to 50 questions.', + ], + 'missing question' => [ + $faq([['question' => '', 'answer' => '

Yes

']]), + 'homepage_sections.0.faqs.0.question', + 'Every FAQ needs a question.', + ], + 'question too long' => [ + $faq([['question' => str_repeat('q', 301), 'answer' => '

Yes

']]), + 'homepage_sections.0.faqs.0.question', + 'FAQ questions can be up to 300 characters.', + ], + 'answer too long' => [ + $faq([['question' => 'Parking?', 'answer' => '

'.str_repeat('a', 5000).'

']]), + 'homepage_sections.0.faqs.0.answer', + 'FAQ answers can be up to 5000 characters.', + ], + 'video link not https' => [ + [['id' => 'v', 'type' => 'VIDEO', 'video_url' => 'http://youtube.com/watch?v=abc']], + 'homepage_sections.0.video_url', + 'Use a YouTube or Vimeo link starting with https://', + ], + 'video link too long' => [ + [['id' => 'v', 'type' => 'VIDEO', 'video_url' => 'https://youtube.com/watch?v='.str_repeat('a', 480)]], + 'homepage_sections.0.video_url', + 'Video links can be up to 500 characters.', + ], + 'too many photos' => [ + [['id' => 'photos', 'type' => 'GALLERY', 'image_ids' => range(1, 41)]], + 'homepage_sections.0.image_ids', + 'A gallery can have up to 40 photos.', + ], + 'photo id not a number' => [ + [['id' => 'photos', 'type' => 'GALLERY', 'image_ids' => ['first']]], + 'homepage_sections.0.image_ids.0', + self::INVALID_SECTION, + ], + 'too many sponsors' => [ + $sponsors(array_map(fn (int $id) => ['image_id' => $id], range(1, 61))), + 'homepage_sections.0.sponsors', + 'You can add up to 60 sponsors.', + ], + 'sponsor without a logo' => [ + $sponsors([['name' => 'Acme']]), + 'homepage_sections.0.sponsors.0.image_id', + 'Every sponsor needs a logo.', + ], + 'sponsor name too long' => [ + $sponsor(['name' => str_repeat('n', 101)]), + 'homepage_sections.0.sponsors.0.name', + 'Sponsor names can be up to 100 characters.', + ], + 'sponsor link not https' => [ + $sponsor(['url' => 'acme.test']), + 'homepage_sections.0.sponsors.0.url', + 'Sponsor links must start with https://', + ], + 'sponsor link too long' => [ + $sponsor(['url' => 'https://acme.test/'.str_repeat('a', 490)]), + 'homepage_sections.0.sponsors.0.url', + 'Sponsor links can be up to 500 characters.', + ], + ]; + } + + #[DataProvider('invalidSections')] + public function test_event_homepage_section_errors_are_readable(mixed $sections, string $field, string $message): void + { + [$owner, $accountId] = $this->createAccountOwner(); + $organizerId = $this->createOrganizer($accountId); + DB::table('organizer_settings')->insert(['organizer_id' => $organizerId, 'created_at' => now(), 'updated_at' => now()]); + $headers = $this->headersFor($owner, $accountId); + $eventId = $this->postJson('/events', [ + 'title' => 'Sections test', + 'type' => 'SINGLE', + 'organizer_id' => $organizerId, + 'start_date' => '2030-06-14 19:00', + 'currency' => 'EUR', + 'timezone' => 'UTC', + ], $headers)->assertSuccessful()->json('data.id'); + + $errors = $this->patchJson("/events/$eventId/settings", ['homepage_sections' => $sections], $headers) + ->assertUnprocessable() + ->json('errors'); + + $this->assertSame($message, $errors[$field][0] ?? null, json_encode($errors)); + $this->assertStringNotContainsString('homepage_sections', json_encode(array_values($errors))); + } + + public function test_organizer_homepage_section_errors_are_readable(): void + { + [$owner, $accountId] = $this->createAccountOwner(); + $organizerId = $this->createOrganizer($accountId); + DB::table('organizer_settings')->insert(['organizer_id' => $organizerId, 'created_at' => now(), 'updated_at' => now()]); + + $errors = $this->patchJson("/organizers/$organizerId/settings", [ + 'homepage_sections' => [ + ['id' => 'faq', 'type' => 'FAQ', 'faqs' => [['question' => 'Parking?', 'answer' => str_repeat('a', 5001)]]], + ['id' => 'tickets', 'type' => 'TICKETS'], + ], + ], $this->headersFor($owner, $accountId)) + ->assertUnprocessable() + ->json('errors'); + + $this->assertSame('FAQ answers can be up to 5000 characters.', $errors['homepage_sections.0.faqs.0.answer'][0] ?? null); + $this->assertSame(self::INVALID_SECTION, $errors['homepage_sections.1.type'][0] ?? null); + } +} diff --git a/backend/tests/Feature/Http/Actions/Lineup/LineupHtmlPurificationTest.php b/backend/tests/Feature/Http/Actions/Lineup/LineupHtmlPurificationTest.php new file mode 100644 index 0000000000..dc3e295f39 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Lineup/LineupHtmlPurificationTest.php @@ -0,0 +1,109 @@ +createAccountOwner(); + $this->organizerId = $this->createOrganizer($accountId); + $this->eventId = $this->createEvent($accountId, $this->organizerId); + $this->headers = $this->headersFor($owner, $accountId); + } + + public function test_import_strips_unsafe_html_from_bios_and_slot_descriptions(): void + { + $existingProfileId = $this->postJson("/organizers/$this->organizerId/profiles", [ + 'name' => 'Nova Lights', + 'bio' => '

Old bio

', + ], $this->headers)->assertCreated()->json('data.id'); + + $this->postJson("/events/$this->eventId/lineup/import", [ + 'people' => [ + ['name' => 'Nova Lights', 'bio' => '

New bio

'], + ['name' => 'Ada Byron', 'bio' => '

Hello

'], + ['name' => 'Blank Bio', 'bio' => ''], + ], + 'slots' => [ + [ + 'starts_at' => '2030-06-14 19:00:00', + 'ends_at' => '2030-06-14 19:30:00', + 'title' => 'Doors', + 'description' => '

Doors open here

', + ], + [ + 'starts_at' => '2030-06-14 20:00:00', + 'ends_at' => '2030-06-14 20:30:00', + 'title' => 'Empty notes', + 'description' => '', + ], + ], + ], $this->headers) + ->assertOk() + ->assertJson(['people_created' => 2, 'people_updated' => 1, 'slots_created' => 2]); + + $updatedBio = DB::table('profiles')->where('id', $existingProfileId)->value('bio'); + $this->assertStringContainsString('New bio', $updatedBio); + $this->assertStringNotContainsString('onerror', $updatedBio); + + $this->assertSame('

Hello

', $this->bioFor('Ada Byron')); + $this->assertNull($this->bioFor('Blank Bio')); + + $doors = $this->slotDescription('Doors'); + $this->assertStringContainsString('Doors open', $doors); + $this->assertStringNotContainsString('javascript:', $doors); + $this->assertStringNotContainsString('onclick', $doors); + $this->assertNull($this->slotDescription('Empty notes')); + } + + public function test_schedule_items_strip_unsafe_html_and_store_empty_descriptions_as_null(): void + { + $slotId = $this->postJson("/events/$this->eventId/lineup/schedule-items", [ + 'title' => 'Doors', + 'description' => '

Notes

', + 'starts_at' => '2030-06-14 19:00', + 'ends_at' => '2030-06-14 20:00', + ], $this->headers)->assertCreated()->json('data.id'); + + $description = $this->slotDescription('Doors'); + $this->assertStringContainsString('Notes', $description); + $this->assertStringNotContainsString('onerror', $description); + $this->assertStringNotContainsString('putJson("/events/$this->eventId/lineup/schedule-items/$slotId", [ + 'title' => 'Doors', + 'description' => '', + 'starts_at' => '2030-06-14 19:00', + 'ends_at' => '2030-06-14 20:00', + ], $this->headers)->assertOk(); + + $this->assertNull($this->slotDescription('Doors')); + } + + private function bioFor(string $name): ?string + { + return DB::table('profiles')->where(['organizer_id' => $this->organizerId, 'name' => $name])->value('bio'); + } + + private function slotDescription(string $title): ?string + { + return DB::table('event_schedule_items')->where(['event_id' => $this->eventId, 'title' => $title])->value('description'); + } +} diff --git a/backend/tests/Feature/Http/Actions/Lineup/LineupImportLockTest.php b/backend/tests/Feature/Http/Actions/Lineup/LineupImportLockTest.php new file mode 100644 index 0000000000..cb74d06aa7 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Lineup/LineupImportLockTest.php @@ -0,0 +1,55 @@ +createAccountOwner(); + $organizerId = $this->createOrganizer($accountId); + $eventId = $this->createEvent($accountId, $organizerId); + $headers = $this->headersFor($owner, $accountId); + + $queries = []; + DB::listen(function (QueryExecuted $query) use (&$queries) { + $queries[] = $query->sql; + }); + + $this->postJson("/events/$eventId/lineup/import", [ + 'people' => [['name' => 'Nova Lights']], + 'slots' => [[ + 'starts_at' => '2030-06-14 19:00:00', + 'ends_at' => '2030-06-14 20:00:00', + 'people' => ['Nova Lights'], + ]], + ], $headers)->assertOk()->assertJson(['people_created' => 1, 'slots_created' => 1]); + + $eventRowLocks = array_filter( + $queries, + static fn (string $sql) => preg_match('/from "events".*\bfor (update|no key update|share)\b/is', $sql) === 1, + ); + $this->assertSame([], array_values($eventRowLocks)); + + $this->assertSame(1, $this->heldAdvisoryLocks(TransactionLockService::LINEUP_LOCK_KEYSPACE, $eventId)); + $this->assertSame(0, $this->heldAdvisoryLocks(TransactionLockService::EVENT_LOCK_KEYSPACE, $eventId)); + } + + private function heldAdvisoryLocks(int $keyspace, int $key): int + { + return (int) DB::selectOne( + "SELECT count(*) AS held FROM pg_locks WHERE locktype = 'advisory' AND pid = pg_backend_pid() AND granted AND classid::bigint = ? AND objid::bigint = ? AND objsubid = 2", + [$keyspace, $key], + )->held; + } +} diff --git a/backend/tests/Feature/Http/Actions/Lineup/LineupMalformedInputTest.php b/backend/tests/Feature/Http/Actions/Lineup/LineupMalformedInputTest.php new file mode 100644 index 0000000000..c3cb64c32e --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Lineup/LineupMalformedInputTest.php @@ -0,0 +1,110 @@ +createAccountOwner(); + $this->organizerId = $this->createOrganizer($accountId); + $this->eventId = $this->createEvent($accountId, $this->organizerId); + $this->headers = $this->headersFor($owner, $accountId); + } + + public function test_sorting_lineup_entries_with_nested_ids_is_a_validation_error(): void + { + $this->postJson("/events/$this->eventId/lineup/entries/sort", [ + 'sorted_ids' => [[1], [2]], + ], $this->headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['sorted_ids.0', 'sorted_ids.1']); + } + + public function test_sorting_profile_images_with_nested_ids_is_a_validation_error(): void + { + $profileId = $this->postJson("/organizers/$this->organizerId/profiles", ['name' => 'Nova'], $this->headers)->json('data.id'); + + $this->postJson("/organizers/$this->organizerId/profiles/$profileId/images/sort", [ + 'sorted_ids' => [[1], [2]], + ], $this->headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['sorted_ids.0', 'sorted_ids.1']); + } + + public function test_creating_a_schedule_item_with_nested_values_is_a_validation_error(): void + { + $this->postJson("/events/$this->eventId/lineup/schedule-items", [ + 'title' => ['Doors'], + 'starts_at' => '2030-06-14 19:00', + 'ends_at' => '2030-06-14 20:00', + 'lineup_entry_ids' => [[1], [1]], + ], $this->headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['title', 'lineup_entry_ids.0', 'lineup_entry_ids.1']); + + $this->assertDatabaseMissing('event_schedule_items', ['event_id' => $this->eventId]); + } + + public function test_updating_a_schedule_item_with_nested_values_is_a_validation_error(): void + { + $slotId = $this->postJson("/events/$this->eventId/lineup/schedule-items", [ + 'title' => 'Doors', + 'starts_at' => '2030-06-14 19:00', + 'ends_at' => '2030-06-14 20:00', + ], $this->headers)->assertCreated()->json('data.id'); + + $this->putJson("/events/$this->eventId/lineup/schedule-items/$slotId", [ + 'title' => ['Doors'], + 'starts_at' => '2030-06-14 19:00', + 'ends_at' => '2030-06-14 20:00', + ], $this->headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['title']); + + $this->assertDatabaseHas('event_schedule_items', ['id' => $slotId, 'title' => 'Doors']); + } + + public function test_a_schedule_item_without_a_title_or_people_still_says_so_when_other_fields_are_invalid(): void + { + $this->postJson("/events/$this->eventId/lineup/schedule-items", [ + 'starts_at' => 'not a date', + 'ends_at' => '2030-06-14 20:00', + ], $this->headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['starts_at']) + ->assertJsonPath('errors.title.0', 'Add a title or at least one person from the lineup'); + } + + public function test_duplicate_ids_are_still_rejected(): void + { + $this->postJson("/events/$this->eventId/lineup/entries/sort", [ + 'sorted_ids' => [5, 5], + ], $this->headers) + ->assertUnprocessable() + ->assertJsonPath('errors.sorted_ids.0', 'Each lineup entry may only appear once'); + + $this->postJson("/events/$this->eventId/lineup/schedule-items", [ + 'starts_at' => '2030-06-14 19:00', + 'ends_at' => '2030-06-14 20:00', + 'lineup_entry_ids' => [7, 7], + ], $this->headers) + ->assertUnprocessable() + ->assertJsonPath('errors.lineup_entry_ids.0', 'Each person may only be added once'); + } +} diff --git a/backend/tests/Feature/Http/Actions/Lineup/PublicLineupScheduleDaysTest.php b/backend/tests/Feature/Http/Actions/Lineup/PublicLineupScheduleDaysTest.php new file mode 100644 index 0000000000..230095fa72 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Lineup/PublicLineupScheduleDaysTest.php @@ -0,0 +1,95 @@ +createAccountOwner(); + $this->eventId = $this->createEvent($accountId, $this->createOrganizer($accountId)); + DB::table('events')->where('id', $this->eventId)->update([ + 'timezone' => 'America/New_York', + 'type' => 'RECURRING', + 'status' => EventStatus::LIVE->name, + ]); + + $this->postJson("/events/$this->eventId/lineup/import", [ + 'people' => [ + ['name' => 'Opening Act'], + ['name' => 'Second Week Act'], + ['name' => 'Resident Host'], + ['name' => 'Secret Guest', 'is_hidden' => true], + ], + 'slots' => [ + ['starts_at' => '2030-06-14 20:00:00', 'ends_at' => '2030-06-14 21:00:00', 'people' => ['Opening Act']], + ['starts_at' => '2030-06-15 01:30:00', 'ends_at' => '2030-06-15 02:30:00', 'people' => ['Opening Act']], + ['starts_at' => '2030-06-28 19:00:00', 'ends_at' => '2030-06-28 20:00:00', 'people' => ['Opening Act']], + ['starts_at' => '2030-06-21 20:00:00', 'ends_at' => '2030-06-21 21:00:00', 'people' => ['Second Week Act', 'Secret Guest']], + ], + ], $this->headersFor($owner, $accountId))->assertOk(); + } + + public function test_a_hidden_schedule_still_says_which_dates_each_person_is_on(): void + { + $this->setSections([['id' => 'schedule', 'type' => 'SCHEDULE', 'enabled' => false]]); + + $lineup = $this->getJson("/public/events/$this->eventId/lineup")->assertOk(); + + $this->assertSame([], $lineup->json('data.schedule_items')); + $this->assertSame( + [ + 'Opening Act' => ['2030-06-14', '2030-06-28'], + 'Second Week Act' => ['2030-06-21'], + 'Resident Host' => [], + ], + collect($lineup->json('data.entries'))->mapWithKeys(fn (array $entry) => [$entry['profile']['name'] => $entry['schedule_days']])->all(), + ); + $this->assertStringNotContainsString('2030-06-15', $lineup->getContent()); + } + + public function test_a_hidden_schedule_on_a_single_date_event_reveals_no_dates(): void + { + DB::table('events')->where('id', $this->eventId)->update(['type' => 'SINGLE']); + $this->setSections([['id' => 'schedule', 'type' => 'SCHEDULE', 'enabled' => false]]); + + $lineup = $this->getJson("/public/events/$this->eventId/lineup") + ->assertOk() + ->assertJsonCount(3, 'data.entries') + ->assertJsonMissingPath('data.entries.0.schedule_days'); + + $this->assertSame([], $lineup->json('data.schedule_items')); + $this->assertStringNotContainsString('2030-06-', $lineup->getContent()); + } + + public function test_schedule_days_are_left_out_when_the_schedule_is_shown(): void + { + $this->setSections([['id' => 'schedule', 'type' => 'SCHEDULE', 'enabled' => true]]); + + $this->getJson("/public/events/$this->eventId/lineup") + ->assertOk() + ->assertJsonCount(4, 'data.schedule_items') + ->assertJsonMissingPath('data.entries.0.schedule_days'); + } + + private function setSections(array $sections): void + { + DB::table('event_settings')->updateOrInsert( + ['event_id' => $this->eventId], + ['homepage_sections' => json_encode($sections), 'created_at' => now(), 'updated_at' => now()], + ); + } +} diff --git a/backend/tests/Feature/Http/Actions/Messages/SendMessageActionTest.php b/backend/tests/Feature/Http/Actions/Messages/SendMessageActionTest.php new file mode 100644 index 0000000000..a5ad3c738e --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Messages/SendMessageActionTest.php @@ -0,0 +1,62 @@ +insertAccountAndOrganizer(); + $this->eventId = $this->insertEvent(EventType::SINGLE->name); + $this->authToken = JWTAuth::claims(['account_id' => $this->accountId])->fromUser(User::find($this->userId)); + } + + public function test_a_message_without_a_recipient_type_is_rejected_and_not_stored(): void + { + $this->sendMessage(['message_type' => null]) + ->assertStatus(422) + ->assertJsonValidationErrors(['message_type']); + + $this->assertSame(0, DB::table('messages')->where('event_id', $this->eventId)->count()); + } + + public function test_an_order_owner_message_without_an_order_is_rejected_and_not_stored(): void + { + $this->sendMessage(['message_type' => 'ORDER_OWNER']) + ->assertStatus(422) + ->assertJsonValidationErrors(['order_id']); + + $this->assertSame(0, DB::table('messages')->where('event_id', $this->eventId)->count()); + } + + private function sendMessage(array $overrides) + { + return $this->withHeader('Authorization', 'Bearer '.$this->authToken) + ->postJson(sprintf('/events/%d/messages', $this->eventId), array_merge([ + 'subject' => 'Doors open at 7', + 'message' => '

See you there

', + 'is_test' => false, + 'send_copy_to_current_user' => false, + 'attendee_ids' => [], + 'product_ids' => [], + 'order_statuses' => ['COMPLETED'], + ], $overrides)); + } +} diff --git a/backend/tests/Feature/Http/Actions/Orders/MarkOrderAsPaidInvoiceEmailTest.php b/backend/tests/Feature/Http/Actions/Orders/MarkOrderAsPaidInvoiceEmailTest.php new file mode 100644 index 0000000000..140e550011 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Orders/MarkOrderAsPaidInvoiceEmailTest.php @@ -0,0 +1,90 @@ +insertAccountAndOrganizer(); + $this->eventId = $this->insertEvent(); + DB::table('event_settings')->insert(['event_id' => $this->eventId, 'created_at' => now(), 'updated_at' => now()]); + $occurrenceId = $this->insertOccurrence(daysAhead: 10); + $productId = $this->insertProduct(); + $priceId = $this->insertPrice($productId, null); + + $this->orderId = $this->insertOrder(OrderStatus::AWAITING_OFFLINE_PAYMENT->name); + DB::table('orders')->where('id', $this->orderId)->update([ + 'email' => 'buyer@example.test', + 'first_name' => 'Robin', + 'last_name' => 'Buyer', + 'total_gross' => 10, + 'total_before_additions' => 10, + 'payment_status' => 'AWAITING_OFFLINE_PAYMENT', + ]); + $this->insertOrderItem($this->orderId, $productId, $priceId, $occurrenceId, 1); + $this->insertAttendee($this->orderId, $productId, $priceId, $occurrenceId, AttendeeStatus::AWAITING_PAYMENT->name); + + $this->invoiceId = DB::table('invoices')->insertGetId([ + 'order_id' => $this->orderId, + 'account_id' => $this->accountId, + 'invoice_number' => 'INV-0001', + 'issue_date' => now(), + 'due_date' => now()->addDays(14), + 'total_amount' => 10, + 'status' => InvoiceStatus::UNPAID->name, + 'items' => json_encode([]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_the_invoice_attached_to_the_marked_as_paid_email_is_the_paid_invoice(): void + { + $renderedInvoiceStatuses = []; + $renderer = Mockery::mock(InvoicePdfRenderer::class); + $renderer->shouldReceive('render') + ->andReturnUsing(function (...$arguments) use (&$renderedInvoiceStatuses) { + /** @var InvoiceDomainObject $invoice */ + $invoice = $arguments[4]; + $renderedInvoiceStatuses[] = [$invoice->getId(), $invoice->getStatus()]; + + return Pdf::loadHTML('invoice'); + }); + $this->app->instance(InvoicePdfRenderer::class, $renderer); + + $this->postJson("/events/{$this->eventId}/orders/{$this->orderId}/mark-as-paid", [], $this->authHeaders()) + ->assertOk(); + + $this->assertSame(InvoiceStatus::PAID->name, DB::table('invoices')->where('id', $this->invoiceId)->value('status')); + $this->assertSame([[$this->invoiceId, InvoiceStatus::PAID->name]], $renderedInvoiceStatuses); + } + + private function authHeaders(): array + { + return ['Authorization' => 'Bearer '.JWTAuth::claims(['account_id' => $this->accountId])->fromUser(User::find($this->userId))]; + } +} diff --git a/backend/tests/Feature/Http/Actions/Orders/Public/GetOrderPublicCheckInStatusTest.php b/backend/tests/Feature/Http/Actions/Orders/Public/GetOrderPublicCheckInStatusTest.php new file mode 100644 index 0000000000..d6feec4469 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Orders/Public/GetOrderPublicCheckInStatusTest.php @@ -0,0 +1,72 @@ +insertAccountAndOrganizer(); + $this->eventId = $this->insertEvent(); + DB::table('event_settings')->insert(['event_id' => $this->eventId, 'created_at' => now(), 'updated_at' => now()]); + $occurrenceId = $this->insertOccurrence(daysAhead: 10); + $this->productId = $this->insertProduct(); + $priceId = $this->insertPrice($this->productId, null); + + $orderId = $this->sellTickets($this->productId, $priceId, $occurrenceId, 1, OrderStatus::COMPLETED->name); + $this->orderShortId = DB::table('orders')->where('id', $orderId)->value('short_id'); + $this->attendeeId = DB::table('attendees')->where('order_id', $orderId)->value('id'); + } + + public function test_the_public_order_says_whether_each_attendee_is_checked_in(): void + { + $this->getJson("/public/events/{$this->eventId}/order/{$this->orderShortId}") + ->assertOk() + ->assertJsonPath('data.attendees.0.is_checked_in', false); + + $this->checkIn($this->attendeeId); + + $this->getJson("/public/events/{$this->eventId}/order/{$this->orderShortId}") + ->assertOk() + ->assertJsonPath('data.attendees.0.is_checked_in', true); + } + + private function checkIn(int $attendeeId): void + { + $checkInListId = DB::table('check_in_lists')->insertGetId([ + 'short_id' => 'cil_'.uniqid(), + 'name' => 'Door', + 'event_id' => $this->eventId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('attendee_check_ins')->insert([ + 'short_id' => 'aci_'.uniqid(), + 'check_in_list_id' => $checkInListId, + 'product_id' => $this->productId, + 'attendee_id' => $attendeeId, + 'event_id' => $this->eventId, + 'ip_address' => '127.0.0.1', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/backend/tests/Feature/Http/Actions/Organizers/OrganizerSettingsAttendeeSelfEditDefaultTest.php b/backend/tests/Feature/Http/Actions/Organizers/OrganizerSettingsAttendeeSelfEditDefaultTest.php new file mode 100644 index 0000000000..73fee18c3b --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Organizers/OrganizerSettingsAttendeeSelfEditDefaultTest.php @@ -0,0 +1,45 @@ +createAccountOwner(); + $organizerId = $this->createOrganizer($accountId); + DB::table('organizer_settings')->insert([ + 'organizer_id' => $organizerId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $headers = $this->headersFor($owner, $accountId); + + $this->getJson("/organizers/$organizerId/settings", $headers) + ->assertOk() + ->assertJsonPath('data.default_allow_attendee_self_edit', true); + + $this->patchJson("/organizers/$organizerId/settings", [ + 'default_show_marketing_opt_in' => false, + 'default_allow_attendee_self_edit' => false, + ], $headers) + ->assertOk() + ->assertJsonPath('data.default_allow_attendee_self_edit', false); + + $this->patchJson("/organizers/$organizerId/settings", ['default_allow_attendee_self_edit' => null], $headers) + ->assertOk() + ->assertJsonPath('data.default_allow_attendee_self_edit', false); + + $this->getJson("/organizers/$organizerId/settings", $headers) + ->assertOk() + ->assertJsonPath('data.default_allow_attendee_self_edit', false); + } +} diff --git a/backend/tests/Feature/Http/Actions/Questions/UpsertQuestionActionTest.php b/backend/tests/Feature/Http/Actions/Questions/UpsertQuestionActionTest.php new file mode 100644 index 0000000000..ae53d704db --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Questions/UpsertQuestionActionTest.php @@ -0,0 +1,102 @@ +createAccountOwner(); + $this->eventId = $this->createEvent($accountId, $this->createOrganizer($accountId)); + $this->headers = $this->headersFor($owner, $accountId); + } + + public function test_an_order_question_can_be_created_and_edited(): void + { + $questionId = $this->postJson("/events/$this->eventId/questions", $this->payload(), $this->headers) + ->assertCreated() + ->assertJsonPath('data.belongs_to', 'ORDER') + ->assertJsonPath('data.type', 'SINGLE_LINE_TEXT') + ->json('data.id'); + + $this->putJson("/events/$this->eventId/questions/$questionId", $this->payload(['title' => 'Dietary needs']), $this->headers) + ->assertOk() + ->assertJsonPath('data.title', 'Dietary needs') + ->assertJsonPath('data.belongs_to', 'ORDER'); + } + + public function test_an_order_question_can_be_created_and_edited_without_product_ids(): void + { + $payload = $this->payload(); + unset($payload['product_ids']); + + $questionId = $this->postJson("/events/$this->eventId/questions", $payload, $this->headers) + ->assertCreated() + ->json('data.id'); + + $this->putJson("/events/$this->eventId/questions/$questionId", [...$payload, 'title' => 'Dietary needs'], $this->headers) + ->assertOk() + ->assertJsonPath('data.title', 'Dietary needs'); + } + + public function test_a_product_question_without_products_is_a_validation_error(): void + { + $this->postJson("/events/$this->eventId/questions", $this->payload(['belongs_to' => 'PRODUCT']), $this->headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['product_ids']) + ->assertJsonMissingValidationErrors(['belongs_to']); + } + + public function test_an_unknown_belongs_to_is_a_validation_error_on_create_and_edit(): void + { + $this->postJson("/events/$this->eventId/questions", $this->payload(['belongs_to' => 'ATTENDEE']), $this->headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['belongs_to']); + + $questionId = $this->postJson("/events/$this->eventId/questions", $this->payload(), $this->headers) + ->assertCreated() + ->json('data.id'); + + $this->putJson("/events/$this->eventId/questions/$questionId", $this->payload(['belongs_to' => 'order']), $this->headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['belongs_to']); + } + + public function test_a_missing_belongs_to_is_a_validation_error(): void + { + $payload = $this->payload(); + unset($payload['belongs_to']); + + $this->postJson("/events/$this->eventId/questions", $payload, $this->headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['belongs_to']); + } + + private function payload(array $overrides = []): array + { + return array_merge([ + 'title' => 'Company name', + 'type' => 'SINGLE_LINE_TEXT', + 'belongs_to' => 'ORDER', + 'product_ids' => [], + 'required' => true, + 'is_hidden' => false, + ], $overrides); + } +} diff --git a/backend/tests/Feature/Http/Actions/SeatMaps/BuyerSeatChangeSelfServiceTest.php b/backend/tests/Feature/Http/Actions/SeatMaps/BuyerSeatChangeSelfServiceTest.php new file mode 100644 index 0000000000..70e1bec164 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/SeatMaps/BuyerSeatChangeSelfServiceTest.php @@ -0,0 +1,86 @@ + 1], [ + 'id' => 1, + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => ['percentage' => 1.5, 'fixed' => 0], + ]); + + $this->insertAccountAndOrganizer(); + $this->eventId = $this->insertEvent(EventType::SINGLE->name); + DB::table('event_settings')->insert(['event_id' => $this->eventId, 'created_at' => now(), 'updated_at' => now()]); + $this->setFeatureFlagOverride($this->accountId, FeatureFlag::SEATING, true); + + $this->occurrenceId = $this->insertOccurrence(); + $this->productId = $this->insertProduct(priceType: 'FREE'); + $this->priceId = $this->insertPrice($this->productId, null); + + $eventSeatMapId = $this->insertEventSeatMap($this->seatMapFixture('theatre'), ['b_premium' => [$this->productId]]); + $this->updateEventSeatMap($eventSeatMapId, ['allow_seat_change' => true]); + } + + public function test_a_buyer_cannot_change_seat_when_self_service_is_off(): void + { + $attendee = $this->postJson("/events/{$this->eventId}/attendees", [ + 'product_id' => $this->productId, + 'product_price_id' => $this->priceId, + 'event_occurrence_id' => $this->occurrenceId, + 'seat_uid' => self::SEAT, + 'email' => 'guest+'.uniqid().'@example.test', + 'first_name' => 'Ada', + 'last_name' => 'Guest', + 'amount_paid' => 0, + 'send_confirmation_email' => false, + 'locale' => 'en', + ], ['Authorization' => 'Bearer '.JWTAuth::claims(['account_id' => $this->accountId])->fromUser(User::find($this->userId))]) + ->assertStatus(ResponseCodes::HTTP_CREATED) + ->json('data'); + $orderShortId = DB::table('orders')->where('id', $attendee['order_id'])->value('short_id'); + + DB::table('event_settings')->where('event_id', $this->eventId)->update(['allow_attendee_self_edit' => false]); + + $this->putJson( + "/public/events/{$this->eventId}/order/$orderShortId/attendees/{$attendee['short_id']}/seat", + ['seat_uid' => self::OTHER_PREMIUM_SEAT], + )->assertStatus(ResponseCodes::HTTP_FORBIDDEN); + + $this->assertDatabaseHas('attendees', ['id' => $attendee['id'], 'seat_uid' => self::SEAT]); + } +} diff --git a/backend/tests/Feature/Http/Actions/TaxesAndFees/TaxOrFeeActionsTest.php b/backend/tests/Feature/Http/Actions/TaxesAndFees/TaxOrFeeActionsTest.php new file mode 100644 index 0000000000..a379662174 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/TaxesAndFees/TaxOrFeeActionsTest.php @@ -0,0 +1,98 @@ +accountId] = $this->createAccountOwner(); + $this->headers = $this->headersFor($owner, $this->accountId); + } + + public function test_a_tax_can_be_created_edited_and_deleted(): void + { + $taxId = $this->postJson("/accounts/$this->accountId/taxes-and-fees", $this->payload(), $this->headers) + ->assertOk() + ->json('data.id'); + + $this->putJson("/accounts/$this->accountId/taxes-and-fees/$taxId", $this->payload(['name' => 'Irish VAT', 'rate' => 23]), $this->headers) + ->assertOk() + ->assertJsonPath('data.id', $taxId) + ->assertJsonPath('data.name', 'Irish VAT'); + + $this->assertEquals(23, DB::table('taxes_and_fees')->where('id', $taxId)->value('rate')); + + $this->deleteJson("/accounts/$this->accountId/taxes-and-fees/$taxId", [], $this->headers) + ->assertNoContent(); + + $this->assertSoftDeleted('taxes_and_fees', ['id' => $taxId]); + } + + public function test_another_accounts_tax_cannot_be_edited_or_deleted(): void + { + [, $otherAccountId] = $this->createAccountOwner(); + $foreignTaxId = DB::table('taxes_and_fees')->insertGetId([ + 'account_id' => $otherAccountId, + 'name' => 'VAT', + 'calculation_type' => 'PERCENTAGE', + 'type' => 'TAX', + 'rate' => 20, + 'is_active' => true, + 'is_default' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->putJson("/accounts/$this->accountId/taxes-and-fees/$foreignTaxId", $this->payload(['rate' => 1]), $this->headers) + ->assertForbidden(); + $this->deleteJson("/accounts/$this->accountId/taxes-and-fees/$foreignTaxId", [], $this->headers) + ->assertForbidden(); + + $this->assertEquals(20, DB::table('taxes_and_fees')->where('id', $foreignTaxId)->value('rate')); + $this->assertNotSoftDeleted('taxes_and_fees', ['id' => $foreignTaxId]); + } + + public function test_a_tax_can_be_created_and_edited_without_a_description(): void + { + $payload = $this->payload(); + unset($payload['description']); + + $taxId = $this->postJson("/accounts/$this->accountId/taxes-and-fees", $payload, $this->headers) + ->assertOk() + ->json('data.id'); + + $this->putJson("/accounts/$this->accountId/taxes-and-fees/$taxId", [...$payload, 'rate' => 23], $this->headers) + ->assertOk() + ->assertJsonPath('data.description', null); + + $this->assertEquals(23, DB::table('taxes_and_fees')->where('id', $taxId)->value('rate')); + } + + private function payload(array $overrides = []): array + { + return array_merge([ + 'name' => 'VAT', + 'description' => 'Standard rate', + 'calculation_type' => 'PERCENTAGE', + 'type' => 'TAX', + 'rate' => 20, + 'is_active' => true, + 'is_default' => false, + ], $overrides); + } +} diff --git a/backend/tests/Feature/Http/Actions/Users/UpdateUserActionTest.php b/backend/tests/Feature/Http/Actions/Users/UpdateUserActionTest.php new file mode 100644 index 0000000000..fa5cda771b --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Users/UpdateUserActionTest.php @@ -0,0 +1,167 @@ +owner, $this->accountId] = $this->createAccountOwner(); + } + + public static function missingLastNames(): array + { + return [ + 'empty string' => [['last_name' => '']], + 'null' => [['last_name' => null]], + 'omitted' => [[]], + ]; + } + + #[DataProvider('missingLastNames')] + public function test_member_without_a_last_name_can_have_their_role_changed(array $lastName): void + { + $member = $this->memberWithoutLastName(); + + $this->putJson("/users/{$member->id}", [ + 'first_name' => 'Solo', + 'role' => Role::VIEWER->value, + ...$lastName, + ], $this->headersFor($this->owner, $this->accountId))->assertOk(); + + $this->assertSame(Role::VIEWER->value, $this->accountUserRow($member)->role); + $this->assertNull(DB::table('users')->where('id', $member->id)->value('last_name')); + } + + public function test_member_without_a_last_name_can_be_deactivated(): void + { + $member = $this->memberWithoutLastName(); + + $this->putJson("/users/{$member->id}", [ + 'first_name' => 'Solo', + 'last_name' => null, + 'role' => Role::ORGANIZER->value, + 'status' => UserStatus::INACTIVE->name, + ], $this->headersFor($this->owner, $this->accountId))->assertOk(); + + $this->assertSame(UserStatus::INACTIVE->name, $this->accountUserRow($member)->status); + } + + public function test_superadmin_owner_can_be_renamed_without_sending_a_role(): void + { + DB::table('account_users') + ->where(['account_id' => $this->accountId, 'user_id' => $this->owner->id]) + ->update(['role' => Role::SUPERADMIN->value]); + + $this->putJson("/users/{$this->owner->id}", [ + 'first_name' => 'Fixed', + 'last_name' => 'Typo', + ], $this->headersFor($this->owner, $this->accountId))->assertOk(); + + $this->assertSame(Role::SUPERADMIN->value, $this->accountUserRow($this->owner)->role); + $this->assertSame('Fixed', DB::table('users')->where('id', $this->owner->id)->value('first_name')); + $this->assertSame('Typo', DB::table('users')->where('id', $this->owner->id)->value('last_name')); + Mail::assertNotQueued(AdminAccessGrantedMail::class); + } + + public function test_admin_owner_can_be_renamed_without_sending_a_role(): void + { + $admin = $this->addAccountUser($this->accountId, Role::ADMIN); + + $this->putJson("/users/{$this->owner->id}", [ + 'first_name' => 'Renamed', + 'last_name' => 'Owner', + ], $this->headersFor($admin, $this->accountId))->assertOk(); + + $this->assertSame(Role::ADMIN->value, $this->accountUserRow($this->owner)->role); + $this->assertSame('Renamed', DB::table('users')->where('id', $this->owner->id)->value('first_name')); + } + + public function test_the_super_admin_role_still_cannot_be_assigned(): void + { + $member = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + $this->putJson("/users/{$member->id}", [ + 'first_name' => 'Would', + 'last_name' => 'Escalate', + 'role' => Role::SUPERADMIN->value, + ], $this->headersFor($this->owner, $this->accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors('role'); + + $this->assertSame(Role::ORGANIZER->value, $this->accountUserRow($member)->role); + } + + public function test_omitting_the_role_keeps_the_members_role_and_access(): void + { + $organizerId = $this->createOrganizer($this->accountId); + $member = $this->addRestrictedAccountUser($this->accountId, Role::ORGANIZER, organizerIds: [$organizerId], permissions: ['VIEW_FINANCIALS' => false]); + + $this->putJson("/users/{$member->id}", [ + 'first_name' => 'Name', + 'last_name' => 'Only', + ], $this->headersFor($this->owner, $this->accountId))->assertOk(); + + $row = $this->accountUserRow($member); + $this->assertSame(Role::ORGANIZER->value, $row->role); + $this->assertSame('RESTRICTED', $row->access_scope); + $this->assertSame([$organizerId], DB::table('account_user_access_grants')->where('account_user_id', $row->id)->pluck('organizer_id')->all()); + $this->assertFalse((bool) DB::table('account_user_permissions')->where(['account_user_id' => $row->id, 'permission' => 'VIEW_FINANCIALS'])->value('granted')); + } + + public function test_access_sent_without_a_role_is_applied_to_the_current_role(): void + { + $organizerId = $this->createOrganizer($this->accountId); + $eventId = $this->createEvent($this->accountId, $organizerId); + $member = $this->addAccountUser($this->accountId, Role::VIEWER); + + $this->putJson("/users/{$member->id}", [ + 'first_name' => 'Scoped', + 'last_name' => 'Viewer', + 'access_scope' => 'RESTRICTED', + 'event_ids' => [$eventId], + ], $this->headersFor($this->owner, $this->accountId))->assertOk(); + + $row = $this->accountUserRow($member); + $this->assertSame(Role::VIEWER->value, $row->role); + $this->assertSame('RESTRICTED', $row->access_scope); + $this->assertSame([$eventId], DB::table('account_user_access_grants')->where('account_user_id', $row->id)->pluck('event_id')->all()); + } + + private function memberWithoutLastName(): User + { + $member = $this->addAccountUser($this->accountId, Role::ORGANIZER); + DB::table('users')->where('id', $member->id)->update(['first_name' => 'Solo', 'last_name' => null]); + + return $member; + } + + private function accountUserRow(User $user): object + { + return DB::table('account_users') + ->where(['account_id' => $this->accountId, 'user_id' => $user->id]) + ->first(); + } +} diff --git a/backend/tests/Feature/Http/Actions/Widgets/WidgetCrudTest.php b/backend/tests/Feature/Http/Actions/Widgets/WidgetCrudTest.php index 6e0872a7bb..5cf3824e25 100644 --- a/backend/tests/Feature/Http/Actions/Widgets/WidgetCrudTest.php +++ b/backend/tests/Feature/Http/Actions/Widgets/WidgetCrudTest.php @@ -132,16 +132,13 @@ public function test_owner_manages_organizer_event_listing_widgets_separately_fr public function test_rejects_invalid_event_widget_payloads(): void { + $categoryId = $this->insertCategory($this->eventId); + $productId = $this->insertProduct($this->eventId, $categoryId); $foreignCategory = $this->insertCategory($this->otherEventId); $foreignProduct = $this->insertProduct($this->otherEventId, $foreignCategory); - $foreignOccurrence = DB::table('event_occurrences')->insertGetId([ - 'short_id' => 'oc_'.uniqid(), - 'event_id' => $this->otherEventId, - 'start_date' => now()->addDay(), - 'status' => 'ACTIVE', - 'created_at' => now(), - 'updated_at' => now(), - ]); + $foreignOccurrence = $this->insertOccurrence($this->otherEventId); + $deletedProduct = $this->insertProduct($this->eventId, $categoryId); + DB::table('products')->where('id', $deletedProduct)->update(['deleted_at' => now()]); $post = fn (array $payload) => $this->postJson("/events/$this->eventId/widgets", ['name' => 'Widget', 'type' => 'TICKETS', ...$payload], $this->headers); @@ -149,13 +146,116 @@ public function test_rejects_invalid_event_widget_payloads(): void $post(['settings' => ['onload' => 'alert(1)']])->assertUnprocessable()->assertJsonValidationErrors('settings'); $post(['settings' => ['layout' => 'grid']])->assertUnprocessable()->assertJsonValidationErrors('settings'); $post(['settings' => ['sections' => ['about', 'about']]])->assertUnprocessable()->assertJsonValidationErrors('settings.sections.1'); - $post(['settings' => ['products' => [$foreignProduct]]])->assertUnprocessable()->assertJsonValidationErrors('settings.products'); - $post(['settings' => ['categories' => [$foreignCategory]]])->assertUnprocessable()->assertJsonValidationErrors('settings.categories'); - $post(['settings' => ['occurrence_id' => $foreignOccurrence]])->assertUnprocessable()->assertJsonValidationErrors('settings.occurrence_id'); + $post(['settings' => ['products' => [$productId, $foreignProduct]]]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['settings.products' => 'One or more selected products do not belong to this event.']); + $post(['settings' => ['products' => [$deletedProduct]]])->assertUnprocessable()->assertJsonValidationErrors('settings.products'); + $post(['settings' => ['products' => [999999999]]])->assertUnprocessable()->assertJsonValidationErrors('settings.products'); + $post(['settings' => ['categories' => [$foreignCategory]]]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['settings.categories' => 'One or more selected categories do not belong to this event.']); + $post(['settings' => ['occurrence_id' => $foreignOccurrence]]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['settings.occurrence_id' => 'The selected date does not belong to this event.']); $this->assertDatabaseMissing('widgets', ['event_id' => $this->eventId]); } + public function test_updating_rejects_newly_selected_ids_that_are_not_on_the_event(): void + { + $categoryId = $this->insertCategory($this->eventId); + $productId = $this->insertProduct($this->eventId, $categoryId); + $staleProductId = $this->insertProduct($this->eventId, $categoryId); + $newlyDeletedProductId = $this->insertProduct($this->eventId, $categoryId); + $foreignCategory = $this->insertCategory($this->otherEventId); + $foreignProduct = $this->insertProduct($this->otherEventId, $foreignCategory); + $foreignOccurrence = $this->insertOccurrence($this->otherEventId); + $storedSettings = ['products' => [$productId, $staleProductId]]; + $widgetId = $this->createEventWidget($this->eventId, $storedSettings); + DB::table('products')->whereIn('id', [$staleProductId, $newlyDeletedProductId])->update(['deleted_at' => now()]); + + $put = fn (array $settings) => $this->putJson("/events/$this->eventId/widgets/$widgetId", [ + 'name' => 'Renamed', + 'type' => 'TICKETS', + 'settings' => $settings, + ], $this->headers); + + $put(['products' => [$productId, $staleProductId, $foreignProduct]])->assertUnprocessable()->assertJsonValidationErrors('settings.products'); + $put(['products' => [$productId, $newlyDeletedProductId]])->assertUnprocessable()->assertJsonValidationErrors('settings.products'); + $put(['products' => [$productId, 999999999]])->assertUnprocessable()->assertJsonValidationErrors('settings.products'); + $put(['categories' => [$foreignCategory]])->assertUnprocessable()->assertJsonValidationErrors('settings.categories'); + $put(['occurrence_id' => $foreignOccurrence])->assertUnprocessable()->assertJsonValidationErrors('settings.occurrence_id'); + + $this->assertDatabaseHas('widgets', ['id' => $widgetId, 'name' => 'Widget']); + $this->assertSame($storedSettings, json_decode(DB::table('widgets')->where('id', $widgetId)->value('settings'), true)); + } + + public function test_saving_drops_selections_deleted_since_the_widget_was_saved(): void + { + $categoryId = $this->insertCategory($this->eventId); + $deletedCategoryId = $this->insertCategory($this->eventId); + $keptProductId = $this->insertProduct($this->eventId, $categoryId); + $deletedProductId = $this->insertProduct($this->eventId, $categoryId); + $deletedOccurrenceId = $this->insertOccurrence($this->eventId); + + $widget = $this->postJson("/events/$this->eventId/widgets", [ + 'name' => 'VIP only', + 'type' => 'TICKETS', + 'settings' => [ + 'products' => [$keptProductId, $deletedProductId], + 'categories' => [$deletedCategoryId], + 'occurrence_id' => $deletedOccurrenceId, + 'hide_promo_code' => true, + ], + ], $this->headers)->assertCreated()->json('data'); + + DB::table('products')->where('id', $deletedProductId)->update(['deleted_at' => now()]); + DB::table('product_categories')->where('id', $deletedCategoryId)->update(['deleted_at' => now()]); + DB::table('event_occurrences')->where('id', $deletedOccurrenceId)->update(['deleted_at' => now()]); + + $this->putJson("/events/$this->eventId/widgets/{$widget['id']}", [ + 'name' => 'Renamed', + 'type' => 'TICKETS', + 'settings' => $widget['settings'], + ], $this->headers) + ->assertOk() + ->assertJsonPath('data.name', 'Renamed') + ->assertJsonPath('data.settings', ['products' => [$keptProductId], 'hide_promo_code' => true]); + + $this->assertSame( + ['products' => [$keptProductId], 'hide_promo_code' => true], + json_decode(DB::table('widgets')->where('id', $widget['id'])->value('settings'), true), + ); + } + + public function test_saving_a_widget_whose_selected_tickets_were_all_deleted_clears_the_selection(): void + { + $categoryId = $this->insertCategory($this->eventId); + $firstProductId = $this->insertProduct($this->eventId, $categoryId); + $secondProductId = $this->insertProduct($this->eventId, $categoryId); + $widget = $this->postJson("/events/$this->eventId/widgets", [ + 'name' => 'VIP only', + 'type' => 'TICKETS', + 'settings' => ['products' => [$firstProductId, $secondProductId], 'button_text' => 'Buy VIP'], + ], $this->headers)->assertCreated()->json('data'); + + DB::table('products')->whereIn('id', [$firstProductId, $secondProductId])->update(['deleted_at' => now()]); + + $this->putJson("/events/$this->eventId/widgets/{$widget['id']}", [ + 'name' => 'VIP only renamed', + 'type' => 'TICKETS', + 'settings' => $widget['settings'], + ], $this->headers) + ->assertOk() + ->assertJsonPath('data.name', 'VIP only renamed') + ->assertJsonPath('data.settings', ['button_text' => 'Buy VIP']); + + $this->assertSame( + ['button_text' => 'Buy VIP'], + json_decode(DB::table('widgets')->where('id', $widget['id'])->value('settings'), true), + ); + } + public function test_widget_ids_are_scoped_to_the_parent_in_the_route(): void { $otherWidget = $this->createEventWidget($this->otherEventId); @@ -279,6 +379,18 @@ private function insertProduct(int $eventId, int $categoryId): int ]); } + private function insertOccurrence(int $eventId): int + { + return DB::table('event_occurrences')->insertGetId([ + 'short_id' => 'oc_'.uniqid(), + 'event_id' => $eventId, + 'start_date' => now()->addDay(), + 'status' => 'ACTIVE', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + private function insertOrder(int $widgetId, OrderStatus $status, string $currency, float $totalGross, float $totalRefunded = 0): void { DB::table('orders')->insert([ diff --git a/backend/tests/Feature/Http/ApiKeys/ApiKeyAuthenticationTest.php b/backend/tests/Feature/Http/ApiKeys/ApiKeyAuthenticationTest.php index 42a018e3a7..94634dc864 100644 --- a/backend/tests/Feature/Http/ApiKeys/ApiKeyAuthenticationTest.php +++ b/backend/tests/Feature/Http/ApiKeys/ApiKeyAuthenticationTest.php @@ -86,6 +86,20 @@ public function test_read_only_keys_cannot_write(): void $this->assertDatabaseMissing('organizers', ['name' => 'Created over the API']); } + public function test_read_only_keys_can_run_exports(): void + { + $eventId = $this->createEvent($this->accountId, $this->organizerId); + $apiKey = $this->createApiKey($this->owner, $this->accountId, ApiKeyAccess::READ_ONLY); + + $this->postJson("/events/$eventId/attendees/export", [], $this->apiKeyHeaders($apiKey)) + ->assertOk() + ->assertDownload('attendees.xlsx'); + + $this->postJson("/events/$eventId/products/sort", [], $this->apiKeyHeaders($apiKey)) + ->assertForbidden() + ->assertJsonPath('error_code', 'API_KEY_READ_ONLY'); + } + public function test_read_write_keys_can_write(): void { $apiKey = $this->createApiKey($this->owner, $this->accountId); diff --git a/backend/tests/Feature/Http/ApiKeys/ApiKeyManagementTest.php b/backend/tests/Feature/Http/ApiKeys/ApiKeyManagementTest.php index 32ee328904..d09af47f2e 100644 --- a/backend/tests/Feature/Http/ApiKeys/ApiKeyManagementTest.php +++ b/backend/tests/Feature/Http/ApiKeys/ApiKeyManagementTest.php @@ -234,6 +234,20 @@ public function test_admins_revoke_a_members_key_and_the_member_is_told(): void Mail::assertQueued(ApiKeyRevokedMail::class, fn (ApiKeyRevokedMail $mail) => $mail->hasTo($organizer->email)); } + public function test_admins_remove_a_members_expired_key_without_emailing_them(): void + { + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + $this->createApiKey($organizer, $this->accountId, expiresInDays: 7); + $keyId = DB::table('personal_access_tokens')->value('id'); + DB::table('personal_access_tokens')->where('id', $keyId)->update(['expires_at' => CarbonImmutable::now()->subDay()]); + + $this->deleteJson("/api-keys/$keyId", [], $this->headersFor($this->owner, $this->accountId))->assertNoContent(); + + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $keyId]); + Mail::assertNotQueued(ApiKeyRevokedMail::class); + Mail::assertNotSent(ApiKeyRevokedMail::class); + } + public function test_keys_from_another_account_are_not_found(): void { [$otherOwner, $otherAccountId] = $this->createAccountOwner(); diff --git a/backend/tests/Feature/Http/BoxOfficeReadRateLimitTest.php b/backend/tests/Feature/Http/BoxOfficeReadRateLimitTest.php new file mode 100644 index 0000000000..1ac1ace771 --- /dev/null +++ b/backend/tests/Feature/Http/BoxOfficeReadRateLimitTest.php @@ -0,0 +1,98 @@ + 100000]); + } + + public function test_box_offices_on_a_shared_venue_ip_each_have_their_own_allowance(): void + { + $this->assertNoneThrottled($this->read('door-a', self::PER_BOX_OFFICE)); + + $this->assertNotSame(429, $this->readOnce('door-b')); + $this->assertSame(429, $this->readOnce('door-a')); + } + + public function test_door_devices_signed_in_to_the_same_box_office_each_have_their_own_allowance(): void + { + $tokens = [$this->doorSessionToken(1), $this->doorSessionToken(2)]; + + foreach ($tokens as $token) { + $this->assertNoneThrottled($this->read('door-a', self::PER_BOX_OFFICE, $token)); + } + + $this->assertNotSame(429, $this->readOnce('door-a')); + $this->assertSame(429, $this->readOnce('door-a', $tokens[0])); + } + + public function test_a_made_up_door_session_token_does_not_buy_a_fresh_allowance(): void + { + $this->assertNoneThrottled($this->read('door-a', self::PER_BOX_OFFICE)); + + $this->assertSame(429, $this->readOnce('door-a', 'bos_made_up')); + } + + public function test_one_ip_is_capped_across_every_box_office(): void + { + for ($boxOffice = 0; $boxOffice < self::PER_IP / self::PER_BOX_OFFICE; $boxOffice++) { + $this->assertNoneThrottled($this->read('door-'.$boxOffice, self::PER_BOX_OFFICE)); + } + + $this->assertSame(429, $this->readOnce('door-new')); + } + + private function doorSessionToken(int $device): string + { + return app(BoxOfficeSessionService::class)->create( + boxOffice: (new BoxOfficeDomainObject)->setId(970000 + $device)->setEventId(1)->setPinHash('hash'), + operatorName: 'Door '.$device, + eventOccurrenceId: null, + stripeTerminalReaderId: null, + )->token; + } + + /** + * @return int[] + */ + private function read(string $shortId, int $times, ?string $sessionToken = null): array + { + $statuses = []; + + for ($request = 0; $request < $times; $request++) { + $statuses[] = $this->readOnce($shortId, $sessionToken); + } + + return $statuses; + } + + private function readOnce(string $shortId, ?string $sessionToken = null): int + { + $headers = $sessionToken === null ? [] : ['X-Box-Office-Session' => $sessionToken]; + + return $this->getJson('/public/box-offices/'.$shortId, $headers)->status(); + } + + /** + * @param int[] $statuses + */ + private function assertNoneThrottled(array $statuses): void + { + $this->assertNotContains(429, $statuses); + } +} diff --git a/backend/tests/Feature/Http/TeamAccess/DuplicateEventAccessTest.php b/backend/tests/Feature/Http/TeamAccess/DuplicateEventAccessTest.php new file mode 100644 index 0000000000..4ead96faf2 --- /dev/null +++ b/backend/tests/Feature/Http/TeamAccess/DuplicateEventAccessTest.php @@ -0,0 +1,88 @@ +accountId] = $this->createAccountOwner(); + $ownerHeaders = $this->headersFor($owner, $this->accountId); + + $this->organizerId = $this->postJson('/organizers', [ + 'name' => 'Organizer A', + 'email' => 'organizer-a@test.com', + 'currency' => 'USD', + 'timezone' => 'UTC', + ], $ownerHeaders)->assertCreated()->json('data.id'); + + $this->eventId = $this->postJson('/events', [ + 'title' => 'Original', + 'organizer_id' => $this->organizerId, + 'start_date' => now()->addMonth()->format('Y-m-d H:i'), + 'timezone' => 'UTC', + 'currency' => 'USD', + ], $ownerHeaders)->assertOk()->json('data.id'); + } + + public function test_duplicating_an_event_requires_an_organizer_grant(): void + { + $eventOnly = $this->addRestrictedAccountUser($this->accountId, eventIds: [$this->eventId]); + $organizerGrant = $this->addRestrictedAccountUser($this->accountId, organizerIds: [$this->organizerId]); + $eventCount = DB::table('events')->count(); + + $this->postJson("/events/{$this->eventId}/lineup/spaces", ['name' => 'Main Stage'], $this->headersFor($eventOnly, $this->accountId)) + ->assertCreated(); + + $this->postJson("/events/{$this->eventId}/duplicate", $this->payload(), $this->headersFor($eventOnly, $this->accountId)) + ->assertForbidden() + ->assertJsonPath('error_code', 'ACCESS_RESTRICTED'); + + $this->assertSame($eventCount, DB::table('events')->count()); + + $copyId = $this->postJson("/events/{$this->eventId}/duplicate", $this->payload(), $this->headersFor($organizerGrant, $this->accountId)) + ->assertOk() + ->assertJsonPath('data.organizer_id', $this->organizerId) + ->assertJsonPath('data.title', 'Copy') + ->json('data.id'); + + $this->assertNotSame($this->eventId, $copyId); + $this->assertSame($eventCount + 1, DB::table('events')->count()); + } + + private function payload(): array + { + return [ + 'title' => 'Copy', + 'start_date' => now()->addMonths(2)->format('Y-m-d H:i'), + 'duplicate_products' => true, + 'duplicate_questions' => true, + 'duplicate_settings' => true, + 'duplicate_promo_codes' => true, + 'duplicate_capacity_assignments' => true, + 'duplicate_check_in_lists' => true, + 'duplicate_event_cover_image' => true, + 'duplicate_webhooks' => true, + 'duplicate_affiliates' => true, + 'duplicate_lineup' => true, + 'duplicate_ticket_logo' => true, + 'duplicate_occurrences' => true, + ]; + } +} diff --git a/backend/tests/Feature/Http/TeamAccess/FinancialActionGatesTest.php b/backend/tests/Feature/Http/TeamAccess/FinancialActionGatesTest.php new file mode 100644 index 0000000000..8140585f49 --- /dev/null +++ b/backend/tests/Feature/Http/TeamAccess/FinancialActionGatesTest.php @@ -0,0 +1,165 @@ +accountId] = $this->createAccountOwner(); + $this->organizerId = $this->createOrganizer($this->accountId); + $this->eventId = $this->createEvent($this->accountId, $this->organizerId); + $this->occurrenceId = DB::table('event_occurrences')->insertGetId([ + 'short_id' => 'oc_'.uniqid(), + 'event_id' => $this->eventId, + 'start_date' => now()->addWeek(), + 'end_date' => now()->addWeek()->addHours(2), + 'status' => 'ACTIVE', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_member_without_refunds_cannot_cancel_a_date_with_refunds(): void + { + $headers = $this->headers($this->organizerWithoutRefunds()); + + $this->postJson($this->cancelPath(), ['refund_orders' => true], $headers) + ->assertForbidden() + ->assertJsonPath('error_code', 'PERMISSION_REQUIRED'); + + $this->assertSame('ACTIVE', $this->occurrenceStatus()); + Queue::assertNotPushed(RefundOccurrenceOrdersJob::class); + + $this->postJson($this->cancelPath(), [], $headers)->assertOk(); + + $this->assertSame('CANCELLED', $this->occurrenceStatus()); + Queue::assertNotPushed(RefundOccurrenceOrdersJob::class); + } + + public function test_member_with_refunds_can_cancel_a_date_with_refunds(): void + { + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + $this->postJson($this->cancelPath(), ['refund_orders' => true], $this->headers($organizer))->assertOk(); + + $this->assertSame('CANCELLED', $this->occurrenceStatus()); + Queue::assertPushed(RefundOccurrenceOrdersJob::class, 1); + } + + public function test_member_without_refunds_cannot_bulk_cancel_dates_with_refunds(): void + { + $headers = $this->headers($this->organizerWithoutRefunds()); + + $this->postJson($this->bulkPath(), $this->bulkCancel(refundOrders: true), $headers) + ->assertForbidden() + ->assertJsonPath('error_code', 'PERMISSION_REQUIRED'); + + Queue::assertNotPushed(BulkCancelOccurrencesJob::class); + + $this->postJson($this->bulkPath(), $this->bulkCancel(refundOrders: false), $headers) + ->assertOk() + ->assertJsonPath('updated_ids', [$this->occurrenceId]); + + Queue::assertPushed(BulkCancelOccurrencesJob::class, fn (BulkCancelOccurrencesJob $job) => $job->occurrenceIds === [$this->occurrenceId] && $job->refundOrders === false); + Queue::assertPushed(BulkCancelOccurrencesJob::class, 1); + } + + public function test_member_with_refunds_can_bulk_cancel_dates_with_refunds(): void + { + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + + $this->postJson($this->bulkPath(), $this->bulkCancel(refundOrders: true), $this->headers($organizer))->assertOk(); + + Queue::assertPushed(BulkCancelOccurrencesJob::class, fn (BulkCancelOccurrencesJob $job) => $job->refundOrders === true); + } + + public function test_financial_report_exports_need_financials_but_check_in_exports_do_not(): void + { + $organizerWithoutFinancials = $this->addAccountUser($this->accountId, Role::ORGANIZER); + $this->setPermissionOverrides($organizerWithoutFinancials, $this->accountId, ['VIEW_FINANCIALS' => false]); + $viewer = $this->addAccountUser($this->accountId, Role::VIEWER); + + foreach ([$organizerWithoutFinancials, $viewer] as $member) { + foreach (['revenue_summary', 'tax_summary', 'events_performance', 'platform_fees'] as $report) { + $this->get($this->exportPath($report), $this->headers($member) + ['Accept' => 'application/json']) + ->assertForbidden() + ->assertJsonPath('error_code', 'PERMISSION_REQUIRED'); + } + + $this->get($this->exportPath('check_in_summary'), $this->headers($member)) + ->assertOk() + ->assertHeader('Content-Type', 'text/csv; charset=utf-8'); + } + + $this->get($this->exportPath('revenue_summary'), $this->headers($this->addAccountUser($this->accountId, Role::ORGANIZER))) + ->assertOk(); + } + + private function organizerWithoutRefunds(): User + { + $user = $this->addAccountUser($this->accountId, Role::ORGANIZER); + $this->setPermissionOverrides($user, $this->accountId, ['ISSUE_REFUNDS' => false]); + + return $user; + } + + private function bulkCancel(bool $refundOrders): array + { + return [ + 'action' => 'cancel', + 'occurrence_ids' => [$this->occurrenceId], + 'refund_orders' => $refundOrders, + ]; + } + + private function occurrenceStatus(): string + { + return DB::table('event_occurrences')->where('id', $this->occurrenceId)->value('status'); + } + + private function cancelPath(): string + { + return "/events/{$this->eventId}/occurrences/{$this->occurrenceId}/cancel"; + } + + private function bulkPath(): string + { + return "/events/{$this->eventId}/occurrences/bulk-update"; + } + + private function exportPath(string $report): string + { + return "/organizers/{$this->organizerId}/reports/{$report}/export"; + } + + private function headers(User $user): array + { + return $this->headersFor($user, $this->accountId); + } +} diff --git a/backend/tests/Feature/Http/TeamAccess/FinancialPermissionsTest.php b/backend/tests/Feature/Http/TeamAccess/FinancialPermissionsTest.php index d388faf124..70c04087bf 100644 --- a/backend/tests/Feature/Http/TeamAccess/FinancialPermissionsTest.php +++ b/backend/tests/Feature/Http/TeamAccess/FinancialPermissionsTest.php @@ -126,6 +126,13 @@ public function test_organizer_with_financials_sees_order_totals(): void public function test_event_list_and_stats_drop_revenue_but_keep_counts(): void { $user = $this->organizerWithoutFinancials(); + DB::table('event_statistics')->insert([ + 'event_id' => $this->eventId, + 'products_sold' => 2, + 'sales_total_gross' => 120.50, + 'created_at' => now(), + 'updated_at' => now(), + ]); $stats = $this->getJson("/events/{$this->eventId}/stats", $this->headers($user))->assertOk()->json('data'); $this->assertArrayHasKey('total_orders', $stats); @@ -140,7 +147,8 @@ public function test_event_list_and_stats_drop_revenue_but_keep_counts(): void $this->assertArrayNotHasKey('total_gross_sales', $organizerStats); $event = $this->getJson('/events', $this->headers($user))->assertOk()->json('data.0'); - $this->assertArrayNotHasKey('sales_total_gross', $event['statistics'] ?? []); + $this->assertSame(2, $event['statistics']['products_sold']); + $this->assertArrayNotHasKey('sales_total_gross', $event['statistics']); } public function test_financial_endpoints_are_refused(): void diff --git a/backend/tests/Feature/Http/TeamAccess/FinancialQueryMetaTest.php b/backend/tests/Feature/Http/TeamAccess/FinancialQueryMetaTest.php new file mode 100644 index 0000000000..f0b319fb90 --- /dev/null +++ b/backend/tests/Feature/Http/TeamAccess/FinancialQueryMetaTest.php @@ -0,0 +1,76 @@ +owner, $this->accountId] = $this->createAccountOwner(); + $this->eventId = $this->createEvent($this->accountId, $this->createOrganizer($this->accountId)); + } + + public function test_members_without_financials_are_not_offered_money_sorts_or_filters(): void + { + $viewer = $this->addAccountUser($this->accountId, Role::VIEWER); + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + $this->setPermissionOverrides($organizer, $this->accountId, ['VIEW_FINANCIALS' => false]); + + foreach ([$viewer, $organizer] as $member) { + $orders = $this->getJson("/events/{$this->eventId}/orders", $this->headersFor($member, $this->accountId)) + ->assertOk() + ->json('meta'); + + $this->assertArrayNotHasKey('total_gross', $orders['allowed_sorts']); + $this->assertArrayHasKey('created_at', $orders['allowed_sorts']); + $this->assertNotContains('total_gross', $orders['allowed_filter_fields']); + $this->assertContains('email', $orders['allowed_filter_fields']); + $this->assertTrue(array_is_list($orders['allowed_filter_fields'])); + + $affiliates = $this->getJson("/events/{$this->eventId}/affiliates", $this->headersFor($member, $this->accountId)) + ->assertOk() + ->json('meta'); + + $this->assertArrayNotHasKey('total_sales_gross', $affiliates['allowed_sorts']); + $this->assertArrayHasKey('total_sales', $affiliates['allowed_sorts']); + } + } + + public function test_members_with_financials_keep_money_sorts_and_filters(): void + { + $viewerWithFinancials = $this->addAccountUser($this->accountId, Role::VIEWER); + $this->setPermissionOverrides($viewerWithFinancials, $this->accountId, ['VIEW_FINANCIALS' => true]); + + foreach ([$this->owner, $viewerWithFinancials] as $member) { + $orders = $this->getJson("/events/{$this->eventId}/orders", $this->headersFor($member, $this->accountId)) + ->assertOk() + ->json('meta'); + + $this->assertSame(['asc' => 'Amount Ascending', 'desc' => 'Amount Descending'], $orders['allowed_sorts']['total_gross']); + $this->assertContains('total_gross', $orders['allowed_filter_fields']); + + $affiliates = $this->getJson("/events/{$this->eventId}/affiliates", $this->headersFor($member, $this->accountId)) + ->assertOk() + ->json('meta'); + + $this->assertArrayHasKey('total_sales_gross', $affiliates['allowed_sorts']); + } + } +} diff --git a/backend/tests/Feature/Http/TeamAccess/NestedMoneyRedactionTest.php b/backend/tests/Feature/Http/TeamAccess/NestedMoneyRedactionTest.php new file mode 100644 index 0000000000..5fc2d68c39 --- /dev/null +++ b/backend/tests/Feature/Http/TeamAccess/NestedMoneyRedactionTest.php @@ -0,0 +1,237 @@ +accountId] = $this->createAccountOwner(); + $this->eventId = $this->createEvent($this->accountId, $this->createOrganizer($this->accountId)); + $this->seedMoneyRows(); + } + + public function test_members_without_financials_get_no_nested_or_secondary_money(): void + { + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + $this->setPermissionOverrides($organizer, $this->accountId, ['VIEW_FINANCIALS' => false]); + $viewer = $this->addAccountUser($this->accountId, Role::VIEWER); + + foreach ([$organizer, $viewer] as $member) { + $responses = $this->fetchMoneySurfaces($member); + + $this->assertSame(2, $responses['order_item']['quantity']); + $this->assertKeysAbsent(self::ORDER_ITEM_MONEY, $responses['order_item']); + + $this->assertSame('CASH', $responses['box_office_order']['box_office_tender']); + $this->assertKeysAbsent(self::BOX_OFFICE_ORDER_MONEY, $responses['box_office_order']); + + $this->assertSame(3, $responses['affiliate']['total_sales']); + $this->assertArrayNotHasKey('total_sales_gross', $responses['affiliate']); + + $this->assertSame(1, $responses['box_office']['sales_count']); + $this->assertArrayNotHasKey('gross_sales', $responses['box_office']); + + $this->assertSame(4, $responses['occurrence_stats']['products_sold']); + $this->assertKeysAbsent(self::OCCURRENCE_STATS_MONEY, $responses['occurrence_stats']); + + $this->assertSame(5, $responses['event_stats']['products_sold']); + $this->assertKeysAbsent(self::EVENT_STATS_MONEY, $responses['event_stats']); + } + } + + public function test_members_with_financials_get_nested_and_secondary_money(): void + { + $organizer = $this->addAccountUser($this->accountId, Role::ORGANIZER); + $viewer = $this->addAccountUser($this->accountId, Role::VIEWER); + $this->setPermissionOverrides($viewer, $this->accountId, ['VIEW_FINANCIALS' => true]); + + foreach ([$organizer, $viewer] as $member) { + $responses = $this->fetchMoneySurfaces($member); + + $this->assertEquals(25.5, $responses['order_item']['price']); + $this->assertEquals(30, $responses['order_item']['price_before_discount']); + $this->assertEquals(51, $responses['order_item']['total_before_additions']); + $this->assertSame([['name' => 'VAT', 'rate' => 10, 'value' => 5.1]], $responses['order_item']['taxes_and_fees_rollup']['taxes']); + + $this->assertEquals(50, $responses['box_office_order']['box_office_amount_tendered']); + $this->assertEquals(10, $responses['box_office_order']['box_office_change_due']); + + $this->assertEquals(300.5, $responses['affiliate']['total_sales_gross']); + $this->assertEquals(40, $responses['box_office']['gross_sales']); + + $this->assertEquals( + ['total_gross_sales' => 410.25, 'total_tax' => 41.5, 'total_fee' => 2.25, 'total_refunded' => 12], + array_intersect_key($responses['occurrence_stats'], array_flip(self::OCCURRENCE_STATS_MONEY)), + ); + $this->assertEquals( + ['sales_total_gross' => 980.75, 'total_tax' => 98, 'sales_total_before_additions' => 882.75, 'total_fee' => 3.5, 'total_refunded' => 20], + array_intersect_key($responses['event_stats'], array_flip(self::EVENT_STATS_MONEY)), + ); + } + } + + /** + * @return array> + */ + private function fetchMoneySurfaces(User $member): array + { + $headers = $this->headersFor($member, $this->accountId); + + $boxOffices = $this->getJson("/events/{$this->eventId}/box-offices", $headers)->assertOk()->json('data'); + $events = $this->getJson('/events', $headers)->assertOk()->json('data'); + + return [ + 'order_item' => $this->getJson("/events/{$this->eventId}/orders/{$this->orderId}", $headers)->assertOk()->json('data.order_items.0'), + 'box_office_order' => $this->getJson("/events/{$this->eventId}/orders/{$this->boxOfficeOrderId}", $headers)->assertOk()->json('data'), + 'affiliate' => $this->getJson("/events/{$this->eventId}/affiliates", $headers)->assertOk()->json('data.0'), + 'box_office' => collect($boxOffices)->firstWhere('name', 'Front Door'), + 'occurrence_stats' => $this->getJson("/events/{$this->eventId}/occurrences", $headers)->assertOk()->json('data.0.statistics'), + 'event_stats' => collect($events)->firstWhere('id', $this->eventId)['statistics'], + ]; + } + + private function assertKeysAbsent(array $keys, array $payload): void + { + foreach ($keys as $key) { + $this->assertArrayNotHasKey($key, $payload); + } + } + + private function seedMoneyRows(): void + { + $occurrenceId = DB::table('event_occurrences')->insertGetId([ + 'short_id' => 'oc_'.uniqid(), + 'event_id' => $this->eventId, + 'start_date' => now()->addWeek(), + 'end_date' => now()->addWeek()->addHours(2), + 'status' => 'ACTIVE', + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('event_occurrence_statistics')->insert([ + 'event_id' => $this->eventId, + 'event_occurrence_id' => $occurrenceId, + 'products_sold' => 4, + 'sales_total_gross' => 410.25, + 'total_tax' => 41.5, + 'total_fee' => 2.25, + 'total_refunded' => 12, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('event_statistics')->insert([ + 'event_id' => $this->eventId, + 'products_sold' => 5, + 'sales_total_gross' => 980.75, + 'total_tax' => 98, + 'sales_total_before_additions' => 882.75, + 'total_fee' => 3.5, + 'total_refunded' => 20, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('affiliates')->insert([ + 'event_id' => $this->eventId, + 'account_id' => $this->accountId, + 'name' => 'Partner', + 'code' => 'PARTNER'.random_int(100, 999), + 'total_sales' => 3, + 'total_sales_gross' => 300.5, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $productId = DB::table('products')->insertGetId([ + 'title' => 'General Admission', + 'event_id' => $this->eventId, + 'order' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $productPriceId = DB::table('product_prices')->insertGetId([ + 'product_id' => $productId, + 'price' => 30, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->orderId = $this->insertOrder(['total_gross' => 56.1, 'total_tax' => 5.1, 'total_before_additions' => 51]); + DB::table('order_items')->insert([ + 'order_id' => $this->orderId, + 'product_id' => $productId, + 'product_price_id' => $productPriceId, + 'event_occurrence_id' => $occurrenceId, + 'item_name' => 'General Admission', + 'quantity' => 2, + 'price' => 25.5, + 'price_before_discount' => 30, + 'total_before_additions' => 51, + 'total_tax' => 5.1, + 'total_gross' => 56.1, + 'taxes_and_fees_rollup' => json_encode(['fees' => [], 'taxes' => [['name' => 'VAT', 'rate' => 10, 'value' => 5.1]]]), + ]); + + $boxOfficeId = DB::table('box_offices')->insertGetId([ + 'event_id' => $this->eventId, + 'short_id' => 'bo_'.uniqid(), + 'name' => 'Front Door', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->boxOfficeOrderId = $this->insertOrder([ + 'total_gross' => 40, + 'total_before_additions' => 40, + 'box_office_id' => $boxOfficeId, + 'box_office_operator_name' => 'Sam', + 'box_office_tender' => 'CASH', + 'box_office_amount_tendered' => 50, + 'box_office_change_due' => 10, + ]); + } + + private function insertOrder(array $attributes): int + { + return DB::table('orders')->insertGetId([ + 'short_id' => 'ord_'.uniqid(), + 'event_id' => $this->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(), + ...$attributes, + ]); + } +} diff --git a/backend/tests/Feature/Http/TeamAccess/OfflinePaymentInstructionsAccessTest.php b/backend/tests/Feature/Http/TeamAccess/OfflinePaymentInstructionsAccessTest.php new file mode 100644 index 0000000000..acb3bb37f4 --- /dev/null +++ b/backend/tests/Feature/Http/TeamAccess/OfflinePaymentInstructionsAccessTest.php @@ -0,0 +1,225 @@ +Pay to IBAN IE00 OWNER 0000

'; + + private const ATTACKER_INSTRUCTIONS = '

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 array + */ + private static function committedReference(): array + { + return json_decode(file_get_contents(base_path('resources/api-reference/openapi.json')), true, flags: JSON_THROW_ON_ERROR); + } + + /** + * @return array> + */ + private static function operations(): array + { + $operations = []; + + foreach (self::committedReference()['paths'] as $path => $pathItem) { + foreach (array_intersect_key($pathItem, array_flip(['get', 'put', 'post', 'delete', 'patch'])) as $method => $operation) { + $operations[strtoupper($method).' '.$path] = $operation; + } + } + + return $operations; + } + + /** + * @return array + */ + private static function emptyArraySchemas(string $json): array + { + preg_match_all('/"(items|additionalProperties|properties|schema|not|contains|propertyNames)":\s*\[\]/', $json, $matches); + + return array_count_values($matches[1]); + } } diff --git a/backend/tests/Feature/Repository/Eloquent/AccountUserAccessGrantRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/AccountUserAccessGrantRepositoryTest.php new file mode 100644 index 0000000000..c428669d56 --- /dev/null +++ b/backend/tests/Feature/Repository/Eloquent/AccountUserAccessGrantRepositoryTest.php @@ -0,0 +1,66 @@ +createAccountOwner(); + $liveOrganizer = $this->createOrganizer($accountId); + $deletedOrganizer = $this->createOrganizer($accountId); + $liveEvent = $this->createEvent($accountId, $liveOrganizer); + $deletedEvent = $this->createEvent($accountId, $liveOrganizer); + + $member = $this->addRestrictedAccountUser( + $accountId, + organizerIds: [$liveOrganizer, $deletedOrganizer], + eventIds: [$liveEvent, $deletedEvent], + ); + $otherMember = $this->addRestrictedAccountUser($accountId, eventIds: [$liveEvent]); + + DB::table('events')->where('id', $deletedEvent)->update(['deleted_at' => now()]); + DB::table('organizers')->where('id', $deletedOrganizer)->update(['deleted_at' => now()]); + + $grants = $this->app->make(AccountUserAccessGrantRepository::class) + ->findLiveGrantsForAccountUsers([$this->accountUserId($member, $accountId)]); + + $this->assertEqualsCanonicalizing( + [['event', $liveEvent], ['organizer', $liveOrganizer]], + $grants->map(fn (AccountUserAccessGrantDomainObject $grant) => $grant->getEventId() !== null + ? ['event', $grant->getEventId()] + : ['organizer', $grant->getOrganizerId()])->all(), + ); + $this->assertNotContains($this->accountUserId($otherMember, $accountId), $grants->map->getAccountUserId()->all()); + } + + public function test_checks_targets_with_correlated_lookups_instead_of_scanning_every_event_and_organizer(): void + { + $queries = []; + DB::listen(function (QueryExecuted $query) use (&$queries) { + $queries[] = $query->sql; + }); + + $this->app->make(AccountUserAccessGrantRepository::class)->findLiveGrantsForAccountUsers([1]); + + $sql = collect($queries)->first(fn (string $sql) => str_contains($sql, 'account_user_access_grants')); + + $this->assertNotNull($sql); + $this->assertStringNotContainsString('in (select', $sql); + $this->assertStringContainsString('"events"."id" = "account_user_access_grants"."event_id"', $sql); + $this->assertStringContainsString('"organizers"."id" = "account_user_access_grants"."organizer_id"', $sql); + } +} diff --git a/backend/tests/Feature/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php b/backend/tests/Feature/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php new file mode 100644 index 0000000000..9af5350d3f --- /dev/null +++ b/backend/tests/Feature/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php @@ -0,0 +1,407 @@ +app->instance(DomainEventDispatcherService::class, Mockery::mock(DomainEventDispatcherService::class)->shouldIgnoreMissing()); + + $this->insertAccountAndOrganizer(); + } + + public function test_a_cancelled_attendee_is_not_reactivated_once_the_price_is_sold_out(): void + { + $this->givenSingleEvent(priceQuantity: 1); + $this->liveAttendee(); + $attendeeId = $this->cancelledAttendee(); + + $message = $this->assertRefused($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertSame(__('This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.'), $message); + + $this->assertCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 1); + } + + public function test_a_cancelled_attendee_is_not_reactivated_once_its_capacity_assignment_is_full(): void + { + $this->givenSingleEvent(); + $this->insertCapacityAssignment(capacity: 1, usedCapacity: 1); + $this->liveAttendee(); + $attendeeId = $this->cancelledAttendee(); + + $message = $this->assertRefused($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertSame(__('The capacity shared by this ticket is full.'), $message); + $this->assertCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 1, poolUsed: 1); + } + + public function test_a_cancelled_attendee_is_not_reactivated_once_its_date_is_full(): void + { + $this->givenSingleEvent(occurrenceCapacity: 1); + $this->liveAttendee(); + $attendeeId = $this->cancelledAttendee(); + + $message = $this->assertRefused($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertSame(__('This event is sold out'), $message); + $this->assertCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 1); + } + + public function test_a_cancelled_attendee_is_not_reactivated_onto_a_cancelled_date(): void + { + $this->givenSingleEvent(); + $attendeeId = $this->cancelledAttendee(); + DB::table('event_occurrences')->where('id', $this->occurrenceId)->update(['status' => EventOccurrenceStatus::CANCELLED->name]); + + $message = $this->assertRefused($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertSame(__('This event has been cancelled'), $message); + $this->assertCounters(priceSold: 0, occurrenceUsed: 0, attendeesRegistered: 0); + } + + public function test_a_cancelled_attendee_is_not_reactivated_once_the_per_date_quantity_of_their_date_is_used(): void + { + $this->givenRecurringEventWithPerDateQuantity(perDateQuantity: 1); + $this->liveAttendee(); + $attendeeId = $this->cancelledAttendee(); + + $this->assertRefused($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 1); + } + + public function test_a_full_date_does_not_stop_a_reactivation_on_another_date_with_room_for_a_per_date_quantity(): void + { + $this->givenRecurringEventWithPerDateQuantity(perDateQuantity: 1); + $this->liveAttendee(); + $otherDateId = $this->insertOccurrence(daysAhead: 8); + $attendeeId = $this->cancelledAttendee($otherDateId); + + $this->changeStatus($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertSame(AttendeeStatus::ACTIVE->name, $this->statusOf($attendeeId)); + $this->assertSame(2, $this->priceSold()); + $this->assertSame(1, $this->occurrenceUsed()); + $this->assertSame(1, (int) DB::table('event_occurrences')->where('id', $otherDateId)->value('used_capacity')); + } + + public function test_a_cancelled_attendee_is_not_reactivated_while_a_reserved_checkout_holds_the_last_place_in_its_capacity_assignment(): void + { + $this->givenSingleEvent(); + $this->insertCapacityAssignment(capacity: 2, usedCapacity: 1); + $this->liveAttendee(); + $this->reserve($this->productId, $this->priceId, $this->occurrenceId, 1); + $attendeeId = $this->cancelledAttendee(); + + $message = $this->assertRefused($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertSame(__('The capacity shared by this ticket is full.'), $message); + $this->assertCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 1, poolUsed: 1); + } + + public function test_a_cancelled_attendee_is_not_reactivated_while_a_waitlist_offer_holds_the_last_place_on_their_date(): void + { + $this->givenSingleEvent(occurrenceCapacity: 2); + $this->liveAttendee(); + $offerOrderId = $this->reserve($this->productId, $this->priceId, $this->occurrenceId, 1); + DB::table('waitlist_entries')->insert([ + 'event_id' => $this->eventId, + 'product_price_id' => $this->priceId, + 'event_occurrence_id' => $this->occurrenceId, + 'email' => 'waiting@example.test', + 'first_name' => 'Waiting', + 'status' => WaitlistEntryStatus::OFFERED->name, + 'offer_token' => 'offer_'.uniqid(), + 'offered_at' => now(), + 'offer_expires_at' => now()->addMinutes(15), + 'order_id' => $offerOrderId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $attendeeId = $this->cancelledAttendee(); + + $message = $this->assertRefused($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertSame(__('Not enough capacity available for this event'), $message); + $this->assertCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 1); + } + + public function test_a_cancelled_attendee_is_reactivated_when_there_is_room_and_every_count_moves_exactly_once(): void + { + $this->givenSingleEvent(priceQuantity: 5, occurrenceCapacity: 5); + $this->insertCapacityAssignment(capacity: 5, usedCapacity: 1); + $this->liveAttendee(); + $attendeeId = $this->cancelledAttendee(); + + $this->changeStatus($attendeeId, AttendeeStatus::ACTIVE); + $this->changeStatus($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertSame(AttendeeStatus::ACTIVE->name, $this->statusOf($attendeeId)); + $this->assertCounters(priceSold: 2, occurrenceUsed: 2, attendeesRegistered: 2, poolUsed: 2); + } + + public function test_the_last_place_can_be_taken_by_a_reactivation(): void + { + $this->givenSingleEvent(priceQuantity: 2, occurrenceCapacity: 2); + $this->insertCapacityAssignment(capacity: 2, usedCapacity: 1); + $this->liveAttendee(); + $attendeeId = $this->cancelledAttendee(); + + $this->changeStatus($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertCounters(priceSold: 2, occurrenceUsed: 2, attendeesRegistered: 2, poolUsed: 2); + } + + public function test_moving_between_active_and_awaiting_payment_leaves_counters_and_statistics_alone(): void + { + $this->givenSingleEvent(priceQuantity: 1, occurrenceCapacity: 1); + $orderId = $this->insertOrder(OrderStatus::AWAITING_OFFLINE_PAYMENT->name); + $this->insertOrderItem($orderId, $this->productId, $this->priceId, $this->occurrenceId, 1); + $this->insertAttendee($orderId, $this->productId, $this->priceId, $this->occurrenceId, AttendeeStatus::AWAITING_PAYMENT->name); + $attendeeId = $this->attendeeOf($orderId); + $this->setCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 0); + + $this->changeStatus($attendeeId, AttendeeStatus::ACTIVE); + $this->assertSame(AttendeeStatus::ACTIVE->name, $this->statusOf($attendeeId)); + $this->assertCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 0); + + $this->changeStatus($attendeeId, AttendeeStatus::AWAITING_PAYMENT); + $this->assertSame(AttendeeStatus::AWAITING_PAYMENT->name, $this->statusOf($attendeeId)); + $this->assertCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 0); + } + + public function test_bringing_a_cancelled_attendee_back_as_awaiting_payment_is_refused_once_the_price_is_sold_out(): void + { + $this->givenSingleEvent(priceQuantity: 1); + $this->liveAttendee(); + $attendeeId = $this->cancelledAttendee(orderStatus: OrderStatus::AWAITING_OFFLINE_PAYMENT->name); + + $this->assertRefused($attendeeId, AttendeeStatus::AWAITING_PAYMENT); + + $this->assertCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 1); + } + + public function test_bringing_a_cancelled_attendee_back_as_awaiting_payment_counts_them_again(): void + { + $this->givenSingleEvent(priceQuantity: 5, occurrenceCapacity: 5); + $this->liveAttendee(); + $attendeeId = $this->cancelledAttendee(orderStatus: OrderStatus::AWAITING_OFFLINE_PAYMENT->name); + + $this->changeStatus($attendeeId, AttendeeStatus::AWAITING_PAYMENT); + + $this->assertSame(AttendeeStatus::AWAITING_PAYMENT->name, $this->statusOf($attendeeId)); + $this->assertCounters(priceSold: 2, occurrenceUsed: 2, attendeesRegistered: 2); + } + + public function test_cancelling_an_attendee_still_frees_their_place_once(): void + { + $this->givenSingleEvent(priceQuantity: 1, occurrenceCapacity: 1); + $this->insertCapacityAssignment(capacity: 1, usedCapacity: 1); + $attendeeId = $this->liveAttendee(); + + $this->changeStatus($attendeeId, AttendeeStatus::CANCELLED); + $this->changeStatus($attendeeId, AttendeeStatus::CANCELLED); + + $this->assertSame(AttendeeStatus::CANCELLED->name, $this->statusOf($attendeeId)); + $this->assertCounters(priceSold: 0, occurrenceUsed: 0, attendeesRegistered: 0, poolUsed: 0); + } + + public function test_a_seated_attendee_whose_seat_is_free_is_not_reactivated_when_the_date_is_full(): void + { + $this->givenSingleEvent(occurrenceCapacity: 1); + $this->insertEventSeatMap($this->seatMapFixture('theatre'), ['b_premium' => [$this->productId]]); + $this->liveAttendee(); + $attendeeId = $this->cancelledAttendee(); + DB::table('attendees')->where('id', $attendeeId)->update(['seat_uid' => self::SEAT, 'seat_label' => 'Stalls · A-1']); + + $this->assertRefused($attendeeId, AttendeeStatus::ACTIVE); + + $this->assertSame(0, DB::table('seat_claims')->where('attendee_id', $attendeeId)->count()); + $this->assertCounters(priceSold: 1, occurrenceUsed: 1, attendeesRegistered: 1); + } + + private function givenSingleEvent(?int $priceQuantity = null, ?int $occurrenceCapacity = null): void + { + $this->eventId = $this->insertEvent(EventType::SINGLE->name); + $this->occurrenceId = $this->insertOccurrence(capacity: $occurrenceCapacity); + $this->productId = $this->insertProduct(); + $this->priceId = $this->insertPrice($this->productId, $priceQuantity, ProductQuantityAppliesTo::EVENT->name); + $this->insertEventStatistics(); + } + + private function givenRecurringEventWithPerDateQuantity(int $perDateQuantity): void + { + $this->eventId = $this->insertEvent(EventType::RECURRING->name); + $this->occurrenceId = $this->insertOccurrence(); + $this->productId = $this->insertProduct(); + $this->priceId = $this->insertPrice($this->productId, $perDateQuantity, ProductQuantityAppliesTo::OCCURRENCE->name); + $this->insertEventStatistics(); + } + + private function insertEventStatistics(): void + { + DB::table('event_statistics')->insert([ + 'event_id' => $this->eventId, + 'products_sold' => 0, + 'attendees_registered' => 0, + 'orders_created' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function insertCapacityAssignment(int $capacity, int $usedCapacity): void + { + $this->capacityAssignmentId = DB::table('capacity_assignments')->insertGetId([ + 'event_id' => $this->eventId, + 'name' => 'Shared pool', + 'capacity' => $capacity, + 'used_capacity' => $usedCapacity, + 'applies_to' => 'PRODUCTS', + 'status' => 'ACTIVE', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('product_capacity_assignments')->insert([ + 'product_id' => $this->productId, + 'capacity_assignment_id' => $this->capacityAssignmentId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function liveAttendee(): int + { + $orderId = $this->insertOrder(OrderStatus::COMPLETED->name); + $this->insertOrderItem($orderId, $this->productId, $this->priceId, $this->occurrenceId, 1); + $this->insertAttendee($orderId, $this->productId, $this->priceId, $this->occurrenceId); + + DB::table('product_prices')->where('id', $this->priceId)->increment('quantity_sold'); + DB::table('event_occurrences')->where('id', $this->occurrenceId)->increment('used_capacity'); + DB::table('event_statistics')->where('event_id', $this->eventId)->increment('attendees_registered'); + + return $this->attendeeOf($orderId); + } + + private function cancelledAttendee(?int $occurrenceId = null, string $orderStatus = OrderStatus::COMPLETED->name): int + { + $occurrenceId ??= $this->occurrenceId; + $orderId = $this->insertOrder($orderStatus); + $this->insertOrderItem($orderId, $this->productId, $this->priceId, $occurrenceId, 1); + $this->insertAttendee($orderId, $this->productId, $this->priceId, $occurrenceId, AttendeeStatus::CANCELLED->name); + + return $this->attendeeOf($orderId); + } + + private function attendeeOf(int $orderId): int + { + return (int) DB::table('attendees')->where('order_id', $orderId)->value('id'); + } + + private function changeStatus(int $attendeeId, AttendeeStatus $status): void + { + app(PartialEditAttendeeHandler::class)->handle(new PartialEditAttendeeDTO( + attendee_id: $attendeeId, + event_id: $this->eventId, + first_name: null, + last_name: null, + email: null, + status: $status->name, + )); + } + + private function assertRefused(int $attendeeId, AttendeeStatus $status): string + { + try { + $this->changeStatus($attendeeId, $status); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('status', $exception->errors()); + $this->assertSame(AttendeeStatus::CANCELLED->name, $this->statusOf($attendeeId)); + + return $exception->errors()['status'][0]; + } + + $this->fail('Expected the reactivation to be refused'); + } + + private function statusOf(int $attendeeId): string + { + return DB::table('attendees')->where('id', $attendeeId)->value('status'); + } + + private function priceSold(): int + { + return (int) DB::table('product_prices')->where('id', $this->priceId)->value('quantity_sold'); + } + + private function occurrenceUsed(): int + { + return (int) DB::table('event_occurrences')->where('id', $this->occurrenceId)->value('used_capacity'); + } + + private function setCounters(int $priceSold, int $occurrenceUsed, int $attendeesRegistered): void + { + DB::table('product_prices')->where('id', $this->priceId)->update(['quantity_sold' => $priceSold]); + DB::table('event_occurrences')->where('id', $this->occurrenceId)->update(['used_capacity' => $occurrenceUsed]); + DB::table('event_statistics')->where('event_id', $this->eventId)->update(['attendees_registered' => $attendeesRegistered]); + } + + private function assertCounters(int $priceSold, int $occurrenceUsed, int $attendeesRegistered, ?int $poolUsed = null): void + { + $this->assertSame([ + 'price_sold' => $priceSold, + 'occurrence_used' => $occurrenceUsed, + 'attendees_registered' => $attendeesRegistered, + 'pool_used' => $poolUsed, + ], [ + 'price_sold' => $this->priceSold(), + 'occurrence_used' => $this->occurrenceUsed(), + 'attendees_registered' => (int) DB::table('event_statistics')->where('event_id', $this->eventId)->value('attendees_registered'), + 'pool_used' => $this->capacityAssignmentId === null + ? null + : (int) DB::table('capacity_assignments')->where('id', $this->capacityAssignmentId)->value('used_capacity'), + ]); + } +} diff --git a/backend/tests/Feature/Services/Application/Handlers/Order/Payment/Stripe/StripeRefundFlowTest.php b/backend/tests/Feature/Services/Application/Handlers/Order/Payment/Stripe/StripeRefundFlowTest.php new file mode 100644 index 0000000000..fc0ace712d --- /dev/null +++ b/backend/tests/Feature/Services/Application/Handlers/Order/Payment/Stripe/StripeRefundFlowTest.php @@ -0,0 +1,298 @@ +insertAccountAndOrganizer(); + $this->eventId = $this->insertEvent(); + DB::table('event_settings')->insert(['event_id' => $this->eventId, 'created_at' => now(), 'updated_at' => now()]); + DB::table('event_statistics')->insert([ + 'event_id' => $this->eventId, + 'sales_total_gross' => 40, + 'sales_total_before_additions' => 40, + 'created_at' => now(), + ]); + + $occurrenceId = $this->insertOccurrence(daysAhead: 10); + $productId = $this->insertProduct(); + $priceId = $this->insertPrice($productId, null, price: 20.00); + + $this->orderId = $this->insertOrder(OrderStatus::COMPLETED->name); + DB::table('orders')->where('id', $this->orderId)->update([ + 'email' => 'buyer@example.test', + 'first_name' => 'Robin', + 'last_name' => 'Buyer', + 'locale' => 'en', + 'total_gross' => 40, + 'total_before_additions' => 40, + 'total_refunded' => 0, + 'payment_provider' => PaymentProviders::STRIPE->name, + 'payment_status' => OrderPaymentStatus::PAYMENT_RECEIVED->name, + ]); + $orderItemId = $this->insertOrderItem($this->orderId, $productId, $priceId, $occurrenceId, 2); + DB::table('order_items')->where('id', $orderItemId)->update(['price' => 20, 'total_before_additions' => 40, 'total_gross' => 40]); + + DB::table('stripe_payments')->insert([ + 'order_id' => $this->orderId, + 'payment_intent_id' => self::PAYMENT_INTENT_ID, + 'connected_account_id' => 'acct_refund_flow', + 'currency' => 'USD', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->stripeRefunds = $this->fakeStripeRefunds(); + + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->shouldReceive('getService')->with('refunds')->andReturn($this->stripeRefunds); + + $stripeClientFactory = Mockery::mock(StripeClientFactory::class); + $stripeClientFactory->shouldReceive('createForPlatform')->andReturn($stripeClient); + $this->app->instance(StripeClientFactory::class, $stripeClientFactory); + } + + public function test_two_identical_partial_refunds_both_go_through_and_the_order_totals_add_up(): void + { + $this->refund(20)->assertOk(); + $this->assertOrder(OrderRefundStatus::REFUND_PENDING, totalRefunded: 0.0); + + $this->stripeSends('re_1', 'pending'); + $this->stripeSends('re_1', 'succeeded'); + $this->assertOrder(OrderRefundStatus::PARTIALLY_REFUNDED, totalRefunded: 20.0); + + $this->refund(20)->assertOk(); + $this->assertSame(['re_1', 're_2'], $this->createdRefundIds()); + $this->assertSame([2000, 2000], array_column($this->stripeRefunds->created, 'amount')); + $this->assertOrder(OrderRefundStatus::REFUND_PENDING, totalRefunded: 20.0); + + $this->stripeSends('re_2', 'succeeded'); + + $this->assertOrder(OrderRefundStatus::REFUNDED, totalRefunded: 40.0); + $this->assertSame(['re_1', 're_2'], DB::table('order_refunds')->where('order_id', $this->orderId)->orderBy('id')->pluck('refund_id')->all()); + $this->assertEquals(40.0, (float) DB::table('event_statistics')->where('event_id', $this->eventId)->value('total_refunded')); + $this->assertEquals(0.0, (float) DB::table('event_statistics')->where('event_id', $this->eventId)->value('sales_total_gross')); + $this->assertRefundEmailsSent([2000, 2000]); + } + + public function test_a_retry_after_stripe_created_the_refund_but_the_response_was_lost_is_deduplicated(): void + { + $this->stripeRefunds->loseNextResponse = true; + + $this->refund(20)->assertUnprocessable(); + $this->assertOrder(null, totalRefunded: 0.0); + + $this->refund(20)->assertOk(); + + $this->assertSame(['re_1'], $this->createdRefundIds()); + $this->assertOrder(OrderRefundStatus::REFUND_PENDING, totalRefunded: 0.0); + + $this->stripeSends('re_1', 'succeeded'); + + $this->assertOrder(OrderRefundStatus::PARTIALLY_REFUNDED, totalRefunded: 20.0); + $this->assertRefundEmailsSent([2000]); + } + + public function test_a_pending_refund_stays_pending_and_blocks_a_second_refund(): void + { + $this->refund(20)->assertOk(); + + $this->stripeSends('re_1', 'pending', pendingReason: 'insufficient_funds'); + + $this->assertOrder(OrderRefundStatus::REFUND_PENDING, totalRefunded: 0.0); + $this->assertSame(0, DB::table('order_refunds')->where('order_id', $this->orderId)->count()); + + $this->refund(10)->assertUnprocessable()->assertJsonValidationErrors('amount'); + + $this->assertSame(['re_1'], $this->createdRefundIds()); + $this->assertOrder(OrderRefundStatus::REFUND_PENDING, totalRefunded: 0.0); + $this->assertRefundEmailsSent([]); + + $this->stripeSends('re_1', 'succeeded'); + + $this->assertOrder(OrderRefundStatus::PARTIALLY_REFUNDED, totalRefunded: 20.0); + $this->assertRefundEmailsSent([2000]); + } + + public function test_a_refund_that_fails_after_pending_is_failed_and_the_buyer_is_not_told_it_was_refunded(): void + { + $this->refund(20)->assertOk(); + + $this->stripeSends('re_1', 'pending'); + $this->stripeSends('re_1', 'failed'); + + $this->assertOrder(OrderRefundStatus::REFUND_FAILED, totalRefunded: 0.0); + $this->assertEquals(0.0, (float) DB::table('event_statistics')->where('event_id', $this->eventId)->value('total_refunded')); + $this->assertRefundEmailsSent([]); + } + + public function test_retrying_a_failed_refund_for_the_same_amount_creates_a_new_refund(): void + { + $this->refund(20)->assertOk(); + $this->stripeSends('re_1', 'failed'); + $this->assertOrder(OrderRefundStatus::REFUND_FAILED, totalRefunded: 0.0); + + $this->refund(20)->assertOk(); + + $this->assertSame(['re_1', 're_2'], $this->createdRefundIds()); + $this->assertOrder(OrderRefundStatus::REFUND_PENDING, totalRefunded: 0.0); + + $this->stripeSends('re_2', 'succeeded'); + + $this->assertOrder(OrderRefundStatus::PARTIALLY_REFUNDED, totalRefunded: 20.0); + $this->assertRefundEmailsSent([2000]); + } + + public function test_the_buyer_is_emailed_once_when_stripe_reports_the_same_success_twice(): void + { + $this->refund(20)->assertOk(); + + $this->stripeSends('re_1', 'succeeded'); + $this->stripeSends('re_1', 'succeeded'); + + $this->assertOrder(OrderRefundStatus::PARTIALLY_REFUNDED, totalRefunded: 20.0); + $this->assertSame(1, DB::table('order_refunds')->where('order_id', $this->orderId)->count()); + $this->assertRefundEmailsSent([2000]); + } + + public function test_the_buyer_is_not_emailed_when_the_organizer_turned_the_notification_off(): void + { + $this->refund(20, notifyBuyer: false)->assertOk(); + + $this->stripeSends('re_1', 'succeeded'); + + $this->assertOrder(OrderRefundStatus::PARTIALLY_REFUNDED, totalRefunded: 20.0); + $this->assertRefundEmailsSent([]); + } + + private function refund(float $amount, bool $notifyBuyer = true): TestResponse + { + $this->app['tymon.jwt']->unsetToken(); + + return $this->postJson("/events/{$this->eventId}/orders/{$this->orderId}/refund", [ + 'amount' => $amount, + 'notify_buyer' => $notifyBuyer, + 'cancel_order' => false, + ], ['Authorization' => 'Bearer '.JWTAuth::claims(['account_id' => $this->accountId])->fromUser(User::find($this->userId))]); + } + + private function stripeSends(string $refundId, string $status, ?string $pendingReason = null): void + { + $created = collect($this->stripeRefunds->created)->firstWhere('id', $refundId); + + app(ChargeRefundUpdatedHandler::class)->handleEvent(Refund::constructFrom(array_merge($created->toArray(), [ + 'status' => $status, + 'pending_reason' => $pendingReason, + ]))); + } + + private function assertOrder(?OrderRefundStatus $refundStatus, float $totalRefunded): void + { + $order = DB::table('orders')->where('id', $this->orderId)->first(); + + $this->assertSame($refundStatus?->name, $order->refund_status); + $this->assertEquals($totalRefunded, (float) $order->total_refunded); + } + + private function createdRefundIds(): array + { + return array_column($this->stripeRefunds->created, 'id'); + } + + private function assertRefundEmailsSent(array $amountsInMinorUnits): void + { + $sent = Mail::queued(OrderRefunded::class) + ->map(function (OrderRefunded $mail) { + $this->assertTrue($mail->hasTo('buyer@example.test')); + + return $mail->content()->with['refundAmount']->toMinorUnit(); + }) + ->values() + ->all(); + + $this->assertSame($amountsInMinorUnits, $sent); + } + + private function fakeStripeRefunds(): RefundService + { + return new class(Mockery::mock(StripeClient::class)) extends RefundService + { + public array $created = []; + + public bool $loseNextResponse = false; + + private array $responsesByIdempotencyKey = []; + + public function create($params = null, $opts = null) + { + $key = $opts['idempotency_key']; + + if (isset($this->responsesByIdempotencyKey[$key])) { + if ($this->responsesByIdempotencyKey[$key]['params'] !== $params) { + throw IdempotencyException::factory('Keys for idempotent requests can only be used with the same parameters they were first used with.'); + } + + return $this->responsesByIdempotencyKey[$key]['refund']; + } + + $refund = Refund::constructFrom([ + 'id' => 're_'.(count($this->created) + 1), + 'object' => 'refund', + 'amount' => $params['amount'], + 'currency' => 'usd', + 'payment_intent' => $params['payment_intent'], + 'status' => 'pending', + 'metadata' => $params['metadata'] ?? [], + ]); + + $this->responsesByIdempotencyKey[$key] = ['params' => $params, 'refund' => $refund]; + $this->created[] = $refund; + + if ($this->loseNextResponse) { + $this->loseNextResponse = false; + + throw ApiConnectionException::factory('Request timed out'); + } + + return $refund; + } + }; + } +} diff --git a/backend/tests/Feature/Services/Domain/Account/Anonymization/OrganizerAnonymizerTest.php b/backend/tests/Feature/Services/Domain/Account/Anonymization/OrganizerAnonymizerTest.php new file mode 100644 index 0000000000..ecfa5f7a39 --- /dev/null +++ b/backend/tests/Feature/Services/Domain/Account/Anonymization/OrganizerAnonymizerTest.php @@ -0,0 +1,114 @@ +seedAccount('acc_orgset_doomed', 'doomed-owner@example.com'); + $organizer = $this->seedOrganizer($account->id, 'Doomed Organizer'); + $settingsId = $this->seedOrganizerSettings($organizer->id); + + $survivingAccount = $this->seedAccount('acc_orgset_survivor', 'surviving-owner@example.com'); + $survivingOrganizer = $this->seedOrganizer($survivingAccount->id, 'Surviving Organizer'); + $survivingSettingsId = $this->seedOrganizerSettings($survivingOrganizer->id); + + $manifest = $this->app->make(AccountAnonymizationService::class) + ->anonymizeAccount($account->id); + + $settings = DB::table('organizer_settings')->where('id', $settingsId)->first(); + $this->assertNull($settings->homepage_sections); + $this->assertNull($settings->website_url); + $this->assertNull($settings->location_details); + $this->assertNull($settings->social_media_handles); + $this->assertNull($settings->seo_title); + $this->assertNull($settings->seo_description); + $this->assertNull($settings->seo_keywords); + + $survivingSettings = DB::table('organizer_settings')->where('id', $survivingSettingsId)->first(); + $this->assertStringContainsString('call Jane on 555-0100', $survivingSettings->homepage_sections); + $this->assertSame('https://jane.example.com', $survivingSettings->website_url); + $this->assertNotNull($survivingSettings->location_details); + $this->assertNotNull($survivingSettings->social_media_handles); + $this->assertSame('Jane Doe Events', $survivingSettings->seo_title); + + $this->assertContains('organizer_settings', array_column($manifest, 'entity')); + } + + private function seedAccount(string $shortId, string $email): Account + { + $account = Account::forceCreate([ + 'name' => 'Account '.$shortId, + 'email' => $email, + 'timezone' => 'UTC', + 'currency_code' => 'USD', + 'short_id' => $shortId, + ]); + + $user = User::forceCreate([ + 'email' => 'user-'.$email, + 'first_name' => 'Owner', + 'last_name' => 'User', + 'password' => 'hash', + 'timezone' => 'UTC', + ]); + + AccountUser::forceCreate([ + 'account_id' => $account->id, + 'user_id' => $user->id, + 'role' => 'ADMIN', + 'status' => 'ACTIVE', + 'is_account_owner' => true, + ]); + + return $account; + } + + private function seedOrganizer(int $accountId, string $name): Organizer + { + return Organizer::forceCreate([ + 'name' => $name, + 'account_id' => $accountId, + 'email' => 'organizer-'.$accountId.'@example.com', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'status' => 'LIVE', + ]); + } + + private function seedOrganizerSettings(int $organizerId): int + { + return DB::table('organizer_settings')->insertGetId([ + 'organizer_id' => $organizerId, + 'homepage_sections' => json_encode([ + ['id' => 'events', 'type' => 'EVENTS', 'enabled' => true], + ['id' => 'travel', 'type' => 'TEXT', 'enabled' => true, 'body' => '

Lost? 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 array */ + private array $sentRequests = []; + + protected function setUp(): void + { + parent::setUp(); + + [$owner, $this->accountId] = $this->createAccountOwner(); + $organizerId = $this->createOrganizer($this->accountId); + $this->eventId = $this->createEvent($this->accountId, $organizerId); + $this->orderShortId = 'ord_'.uniqid(); + $this->orderId = $this->createOrderWithItem(); + + DB::table('webhooks')->insert([ + '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' => $this->accountId, + 'event_id' => $this->eventId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Config::set('app.allowed_internal_webhook_hosts', '127.0.0.1'); + + $handler = HandlerStack::create(new MockHandler([new Response(200, [], 'ok')])); + $handler->push(Middleware::history($this->sentRequests)); + $this->app->instance(Client::class, new Client(['handler' => $handler])); + } + + public function test_order_webhook_keeps_item_prices_when_a_member_without_financials_triggers_it_on_a_sync_queue(): void + { + $member = $this->addAccountUser($this->accountId, Role::ORGANIZER); + $this->setPermissionOverrides($member, $this->accountId, ['VIEW_FINANCIALS' => false]); + + $response = $this->putJson("/events/{$this->eventId}/orders/{$this->orderId}", [ + 'first_name' => 'Ada', + 'last_name' => 'Buyer', + 'email' => 'ada@example.test', + ], $this->headersFor($member, $this->accountId))->assertOk(); + + $this->assertArrayNotHasKey('total_gross', $response->json('data')); + $this->assertSentPayloadHasFullPrices(); + $this->assertLoggedPayloadHasFullPrices(); + } + + public function test_order_webhook_keeps_item_prices_when_dispatched_from_a_queue_worker(): void + { + $this->app['auth']->forgetGuards(); + + app(WebhookDispatchService::class)->dispatchOrderWebhook(DomainEventType::ORDER_UPDATED, $this->orderId); + + $this->assertSentPayloadHasFullPrices(); + $this->assertLoggedPayloadHasFullPrices(); + } + + private function assertSentPayloadHasFullPrices(): void + { + $this->assertCount(1, $this->sentRequests); + + $body = json_decode((string) $this->sentRequests[0]['request']->getBody(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame(DomainEventType::ORDER_UPDATED->value, $body['event_type']); + $this->assertFullPrices($body['payload']); + } + + private function assertLoggedPayloadHasFullPrices(): void + { + $logged = DB::table('webhook_logs')->latest('id')->value('payload'); + + $this->assertNotNull($logged); + $this->assertFullPrices(json_decode($logged, true, flags: JSON_THROW_ON_ERROR)['payload']); + } + + private function assertFullPrices(array $payload): void + { + $this->assertSame($this->orderId, $payload['id']); + $this->assertSame($this->orderShortId, $payload['short_id']); + $this->assertEquals(56.1, $payload['total_gross']); + $this->assertEquals(5.1, $payload['total_tax']); + + $item = $payload['order_items'][0]; + foreach (self::ORDER_ITEM_MONEY as $key => $amount) { + $this->assertArrayHasKey($key, $item); + $this->assertEquals($amount, $item[$key], $key); + } + $this->assertSame([['name' => 'VAT', 'rate' => 10, 'value' => 5.1]], $item['taxes_and_fees_rollup']['taxes']); + } + + private function createOrderWithItem(): int + { + $productId = DB::table('products')->insertGetId([ + 'title' => 'General Admission', + 'event_id' => $this->eventId, + 'order' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $productPriceId = DB::table('product_prices')->insertGetId([ + 'product_id' => $productId, + 'price' => 30, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $orderId = DB::table('orders')->insertGetId([ + 'short_id' => $this->orderShortId, + 'event_id' => $this->eventId, + 'currency' => 'USD', + 'status' => 'COMPLETED', + 'public_id' => 'PUB_'.uniqid(), + 'first_name' => 'Ada', + 'last_name' => 'Buyer', + 'email' => 'ada@example.test', + 'total_before_additions' => 51, + 'total_gross' => 56.1, + 'total_tax' => 5.1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('order_items')->insert([ + 'order_id' => $orderId, + 'product_id' => $productId, + 'product_price_id' => $productPriceId, + 'item_name' => 'General Admission', + 'quantity' => 2, + 'total_tax' => 5.1, + 'total_gross' => 56.1, + 'taxes_and_fees_rollup' => json_encode(['fees' => [], 'taxes' => [['name' => 'VAT', 'rate' => 10, 'value' => 5.1]]]), + ...self::ORDER_ITEM_MONEY, + ]); + + return $orderId; + } +} diff --git a/backend/tests/Unit/Configuration/QueueDatabaseConnectionTest.php b/backend/tests/Unit/Configuration/QueueDatabaseConnectionTest.php new file mode 100644 index 0000000000..ec7ff2d6dd --- /dev/null +++ b/backend/tests/Unit/Configuration/QueueDatabaseConnectionTest.php @@ -0,0 +1,67 @@ +withDbConnectionEnv(null, function (): void { + $database = require base_path('config/database.php'); + $queue = require base_path('config/queue.php'); + + $this->assertSame('pgsql', $database['default']); + $this->assertSame($database['default'], $queue['batching']['database']); + $this->assertSame($database['default'], $queue['failed']['database']); + }); + } + + public function test_job_batches_and_failed_jobs_follow_db_connection_when_it_is_set(): void + { + $this->withDbConnectionEnv('sqlite', function (): void { + $database = require base_path('config/database.php'); + $queue = require base_path('config/queue.php'); + + $this->assertSame('sqlite', $database['default']); + $this->assertSame('sqlite', $queue['batching']['database']); + $this->assertSame('sqlite', $queue['failed']['database']); + }); + } + + private function withDbConnectionEnv(?string $value, Closure $callback): void + { + $original = [ + 'server' => $_SERVER['DB_CONNECTION'] ?? null, + 'env' => $_ENV['DB_CONNECTION'] ?? null, + 'putenv' => getenv('DB_CONNECTION'), + ]; + + $this->setDbConnectionEnv($value, $value, $value === null ? false : $value); + + try { + $callback(); + } finally { + $this->setDbConnectionEnv($original['server'], $original['env'], $original['putenv']); + } + } + + private function setDbConnectionEnv(?string $server, ?string $env, string|false $putenv): void + { + if ($server === null) { + unset($_SERVER['DB_CONNECTION']); + } else { + $_SERVER['DB_CONNECTION'] = $server; + } + + if ($env === null) { + unset($_ENV['DB_CONNECTION']); + } else { + $_ENV['DB_CONNECTION'] = $env; + } + + putenv($putenv === false ? 'DB_CONNECTION' : 'DB_CONNECTION='.$putenv); + } +} diff --git a/backend/tests/Unit/Helper/UrlCdnTest.php b/backend/tests/Unit/Helper/UrlCdnTest.php new file mode 100644 index 0000000000..5642b9b634 --- /dev/null +++ b/backend/tests/Unit/Helper/UrlCdnTest.php @@ -0,0 +1,43 @@ + 'https://cdn.example.com']); + + $this->assertSame('https://cdn.example.com/images/a.png', Url::getCdnUrl('images/a.png')); + } + + public static function unsetCdnUrls(): array + { + return [ + 'unset' => [null], + 'blank' => [''], + ]; + } + + #[DataProvider('unsetCdnUrls')] + public function test_it_falls_back_to_the_public_disk_url_when_no_cdn_url_is_configured(?string $cdnUrl): void + { + config(['app.cdn_url' => $cdnUrl, 'filesystems.public' => 's3-public']); + + $disk = Mockery::mock(Filesystem::class); + $disk->shouldReceive('url')->with('images/a.png')->andReturn('https://bucket.example.com/images/a.png'); + + $filesystem = Mockery::mock(Factory::class); + $filesystem->shouldReceive('disk')->with('s3-public')->andReturn($disk); + $this->app->instance('filesystem', $filesystem); + + $this->assertSame('https://bucket.example.com/images/a.png', Url::getCdnUrl('images/a.png')); + } +} diff --git a/backend/tests/Unit/Http/Request/EventOccurrence/GenerateOccurrencesRequestTest.php b/backend/tests/Unit/Http/Request/EventOccurrence/GenerateOccurrencesRequestTest.php new file mode 100644 index 0000000000..e267f6ad88 --- /dev/null +++ b/backend/tests/Unit/Http/Request/EventOccurrence/GenerateOccurrencesRequestTest.php @@ -0,0 +1,44 @@ + [-1, true], + 'first' => [1, true], + 'fourth' => [4, true], + 'numeric string' => ['2', true], + 'null' => [null, true], + 'zero' => [0, false], + 'fifth' => [5, false], + 'second last' => [-2, false], + 'word' => ['first', false], + ]; + } + + #[DataProvider('weekPositions')] + public function test_week_position_accepts_only_the_documented_positions(mixed $weekPosition, bool $valid): void + { + $validator = Validator::make([ + 'recurrence_rule' => [ + 'frequency' => 'monthly', + 'range' => ['type' => 'count', 'count' => 3], + 'monthly_pattern' => 'by_day_of_week', + 'day_of_week' => 'friday', + 'week_position' => $weekPosition, + ], + ], (new GenerateOccurrencesRequest)->rules()); + + $this->assertSame(! $valid, $validator->errors()->has('recurrence_rule.week_position')); + } +} diff --git a/backend/tests/Unit/Mail/Account/AdminAccessGrantedMailTest.php b/backend/tests/Unit/Mail/Account/AdminAccessGrantedMailTest.php new file mode 100644 index 0000000000..76f5ea9b76 --- /dev/null +++ b/backend/tests/Unit/Mail/Account/AdminAccessGrantedMailTest.php @@ -0,0 +1,48 @@ +render(); + + $this->assertStringContainsString('new-admin@example.com now has admin access', $html); + $this->assertStringContainsString('remove their access', $html); + $this->assertStringNotContainsString('was invited', $html); + } + + public function test_a_pending_invitation_is_described_as_an_invitation(): void + { + $html = (new AdminAccessGrantedMail( + newAdminEmail: 'new-admin@example.com', + grantedByEmail: 'colleague@example.com', + isInvitation: true, + ))->render(); + + $this->assertStringContainsString('new-admin@example.com was invited as an admin', $html); + $this->assertStringContainsString('They get access once they accept', $html); + $this->assertStringContainsString('remove the invitation', $html); + $this->assertStringNotContainsString('now has admin access', $html); + } + + public function test_a_mail_queued_by_a_previous_release_renders_the_access_wording(): void + { + $mail = (new ReflectionClass(AdminAccessGrantedMail::class))->newInstanceWithoutConstructor(); + Closure::bind(function (): void { + $this->newAdminEmail = 'new-admin@example.com'; + $this->grantedByEmail = null; + }, $mail, AdminAccessGrantedMail::class)(); + + $this->assertFalse($mail->content()->with['isInvitation']); + } +} diff --git a/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailOccurrenceDatesTest.php b/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailOccurrenceDatesTest.php new file mode 100644 index 0000000000..afe3534f59 --- /dev/null +++ b/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailOccurrenceDatesTest.php @@ -0,0 +1,168 @@ +occurrence(1, '2026-11-02 19:00:00', null); + $mail = $this->mail($firstDate, collect([ + $firstDate, + $this->occurrence(2, '2026-11-09 19:00:00', null), + $this->occurrence(3, '2026-11-16 19:00:00', null), + ])); + + $html = $mail->render(); + $ics = $this->icsFor($mail); + + $this->assertStringContainsString('Mon, Nov 2, 2026 · 7:00 PM
', $html); + $this->assertStringNotContainsString('Nov 9, 2026', $html); + $this->assertStringNotContainsString('Nov 16, 2026', $html); + $this->assertStringContainsString('DTSTART:20261102T190000', $ics); + $this->assertStringNotContainsString('DTEND', $ics); + $this->assertStringNotContainsString('20261109', $ics); + $this->assertStringNotContainsString('20261116', $ics); + } + + public function test_an_occurrence_with_an_end_shows_its_own_range(): void + { + $firstDate = $this->occurrence(1, '2026-11-02 19:00:00', '2026-11-02 21:00:00'); + $mail = $this->mail($firstDate, collect([ + $firstDate, + $this->occurrence(2, '2026-11-16 19:00:00', '2026-11-16 21:00:00'), + ])); + + $html = $mail->render(); + $ics = $this->icsFor($mail); + + $this->assertStringContainsString('Mon, Nov 2, 2026 · 7:00 PM – 9:00 PM', $html); + $this->assertStringNotContainsString('Nov 16, 2026', $html); + $this->assertStringContainsString('DTEND:20261102T210000', $ics); + $this->assertStringNotContainsString('20261116', $ics); + } + + public function test_an_end_equal_to_the_start_is_not_shown_as_a_range(): void + { + $onlyDate = $this->occurrence(1, '2026-11-02 19:00:00', '2026-11-02 19:00:00'); + $mail = $this->mail($onlyDate, collect([$onlyDate])); + + $html = $mail->render(); + + $this->assertStringContainsString('Mon, Nov 2, 2026 · 7:00 PM
', $html); + $this->assertStringNotContainsString('7:00 PM – 7:00 PM', $html); + $this->assertStringNotContainsString('DTEND', $this->icsFor($mail)); + } + + public function test_without_an_occurrence_a_single_date_event_without_an_end_shows_no_range(): void + { + $onlyDate = $this->occurrence(1, '2026-11-02 19:00:00', null); + $attendee = $this->attendee(null); + + $mail = new AttendeeTicketMail( + order: $this->order(), + attendee: $attendee, + event: $this->event(collect([$onlyDate])), + eventSettings: (new EventSettingDomainObject)->setSupportEmail('support@example.com'), + organizer: $this->organizer(), + ); + + $html = $mail->render(); + + $this->assertStringContainsString('Mon, Nov 2, 2026 · 7:00 PM
', $html); + $this->assertStringNotContainsString('7:00 PM – 7:00 PM', $html); + $this->assertStringNotContainsString('DTEND', $this->icsFor($mail)); + } + + /** + * @param Collection $series + */ + private function mail(EventOccurrenceDomainObject $occurrence, Collection $series): AttendeeTicketMail + { + return new AttendeeTicketMail( + order: $this->order(), + attendee: $this->attendee($occurrence), + event: $this->event($series), + eventSettings: (new EventSettingDomainObject)->setSupportEmail('support@example.com'), + organizer: $this->organizer(), + occurrence: $occurrence, + ); + } + + private function occurrence(int $id, string $start, ?string $end): EventOccurrenceDomainObject + { + return (new EventOccurrenceDomainObject) + ->setId($id) + ->setStartDate($start) + ->setEndDate($end); + } + + /** + * @param Collection $series + */ + private function event(Collection $series): EventDomainObject + { + return (new EventDomainObject) + ->setId(10) + ->setAccountId(1) + ->setTitle('Weekly Quiz') + ->setTimezone('UTC') + ->setEventOccurrences($series); + } + + private function order(): OrderDomainObject + { + return (new OrderDomainObject) + ->setId(30) + ->setShortId('o_short') + ->setStatus(OrderStatus::COMPLETED->name); + } + + private function organizer(): OrganizerDomainObject + { + return (new OrganizerDomainObject) + ->setId(1) + ->setName('Organizer') + ->setEmail('organizer@example.com'); + } + + private function attendee(?EventOccurrenceDomainObject $occurrence): AttendeeDomainObject + { + return (new AttendeeDomainObject) + ->setId(101) + ->setShortId('a_first') + ->setFirstName('Sam') + ->setLastName('Buyer') + ->setEmail('buyer@example.com') + ->setEventOccurrenceId($occurrence?->getId()) + ->setEventOccurrence($occurrence) + ->setProduct((new ProductDomainObject)->setTitle('General Admission')); + } + + private function icsFor(AttendeeTicketMail $mail): string + { + $attachment = $mail->attachments()[0]; + + $data = null; + $attachment->attachWith( + fn () => null, + function ($resolver) use (&$data) { + $data = $resolver(); + }, + ); + + return $data; + } +} diff --git a/backend/tests/Unit/Mail/User/ApiKeyCreatedMailTest.php b/backend/tests/Unit/Mail/User/ApiKeyCreatedMailTest.php new file mode 100644 index 0000000000..01e75368f7 --- /dev/null +++ b/backend/tests/Unit/Mail/User/ApiKeyCreatedMailTest.php @@ -0,0 +1,37 @@ +render(); + + $this->assertStringNotContainsString('</td>', $html); + $this->assertStringNotContainsString('</table>', $html); + $this->assertStringNotContainsString('assertStringNotContainsString('', 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 { + 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 { + 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 { + 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`} - - - - - + + + {props.children} + + + {t`Choose calendar`} + } + onClick={() => window?.open(createGoogleCalendarUrl(entry), '_blank')} + > + {t`Google Calendar`} + + } + onClick={() => downloadICSFile(entry)} + > + {t`Download .ics`} + + + ); }; 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 ( -
+

{description}

diff --git a/frontend/src/components/common/ConfirmIdentity/index.tsx b/frontend/src/components/common/ConfirmIdentity/index.tsx index fb00cce768..bff245ffaa 100644 --- a/frontend/src/components/common/ConfirmIdentity/index.tsx +++ b/frontend/src/components/common/ConfirmIdentity/index.tsx @@ -73,6 +73,7 @@ export const ConfirmIdentityField = ({identity, resumeUrl, passwordTestId, autoF error={identity.error} autoComplete="current-password" autoFocus={autoFocus} + data-autofocus={autoFocus || undefined} required data-testid={passwordTestId} /> diff --git a/frontend/src/components/common/CopyButton/index.tsx b/frontend/src/components/common/CopyButton/index.tsx index 85e6c1d628..8473070879 100644 --- a/frontend/src/components/common/CopyButton/index.tsx +++ b/frontend/src/components/common/CopyButton/index.tsx @@ -4,22 +4,27 @@ import {t} from "@lingui/macro"; interface CopyButtonProps { value: string; + label?: string; } -export const CopyButton = ({value}: CopyButtonProps) => { +export const CopyButton = ({value, label}: CopyButtonProps) => { return ( - {({copied, copy}) => ( - - - {copied ? ( - - ) : ( - - )} - - - )} + {({copied, copy}) => { + const name = copied ? t`Copied` : (label ?? t`Copy`); + + return ( + + + {copied ? ( + + ) : ( + + )} + + + ); + }} ); -} \ No newline at end of file +} diff --git a/frontend/src/components/common/DateField/DateField.module.scss b/frontend/src/components/common/DateField/DateField.module.scss new file mode 100644 index 0000000000..da4fb88603 --- /dev/null +++ b/frontend/src/components/common/DateField/DateField.module.scss @@ -0,0 +1,9 @@ +.root { + min-width: 184px; +} + +.input { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/frontend/src/components/common/DateField/index.tsx b/frontend/src/components/common/DateField/index.tsx index 838f5177ad..3b14e93d69 100644 --- a/frontend/src/components/common/DateField/index.tsx +++ b/frontend/src/components/common/DateField/index.tsx @@ -1,23 +1,36 @@ +import {useId} from "react"; import {DatePickerInput, DatePickerInputProps} from "@mantine/dates"; import {t} from "@lingui/macro"; import {IconCalendar} from "@tabler/icons-react"; import {getDatePickerFormat} from "../../../utilites/dates.ts"; +import classes from "./DateField.module.scss"; interface DateFieldProps extends Omit { value?: string | null; onChange?: (value: string) => void; } -export const DateField = ({value, onChange, required, ...props}: DateFieldProps) => ( - } - {...props} - required={required} - clearable={!required} - value={value || null} - onChange={(next) => onChange?.(next ?? '')} - valueFormat={getDatePickerFormat()} - withNativeLevelSelect - /> -); +export const DateField = ({id, value, onChange, required, ...props}: DateFieldProps) => { + const generatedId = useId(); + const inputId = id ?? generatedId; + + return ( + } + leftSectionPointerEvents="none" + aria-labelledby={props['aria-label'] ? undefined : `${inputId}-label ${inputId}`} + clearButtonProps={{'aria-label': t`Clear`}} + popoverProps={{returnFocus: true}} + {...props} + id={inputId} + classNames={{root: classes.root, input: classes.input}} + required={required} + clearable={!required} + value={value || null} + onChange={(next) => onChange?.(next ?? '')} + valueFormat={getDatePickerFormat()} + withNativeLevelSelect + /> + ); +}; diff --git a/frontend/src/components/common/DateTimeField/DateTimeField.module.scss b/frontend/src/components/common/DateTimeField/DateTimeField.module.scss index ee28f3ab6c..aa5a8ed169 100644 --- a/frontend/src/components/common/DateTimeField/DateTimeField.module.scss +++ b/frontend/src/components/common/DateTimeField/DateTimeField.module.scss @@ -7,11 +7,9 @@ } .date { - flex: 1; - min-width: 0; + flex: 999 1 0; } .time { - flex: 0 0 auto; - width: 132px; + flex: 1 0 132px; } diff --git a/frontend/src/components/common/DateTimeField/dateTimeValue.ts b/frontend/src/components/common/DateTimeField/dateTimeValue.ts new file mode 100644 index 0000000000..c2624a89db --- /dev/null +++ b/frontend/src/components/common/DateTimeField/dateTimeValue.ts @@ -0,0 +1,2 @@ +export const joinDateAndTime = (date: string, time: string, defaultTime: string): string => + date ? `${date}T${time || defaultTime}` : ''; diff --git a/frontend/src/components/common/DateTimeField/index.tsx b/frontend/src/components/common/DateTimeField/index.tsx index d2f054a674..4d59e4c44f 100644 --- a/frontend/src/components/common/DateTimeField/index.tsx +++ b/frontend/src/components/common/DateTimeField/index.tsx @@ -3,6 +3,7 @@ import {Group, Input} from "@mantine/core"; import {TimePicker} from "@mantine/dates"; import {t} from "@lingui/macro"; import {DateField} from "../DateField"; +import {joinDateAndTime} from "./dateTimeValue.ts"; import {getTimePickerFormat} from "../../../utilites/dates.ts"; import classes from "./DateTimeField.module.scss"; @@ -17,16 +18,25 @@ interface DateTimeFieldProps { required?: boolean; disabled?: boolean; minDate?: string; + defaultTime?: string; } -export const DateTimeField = ({value, onChange, onFocus, onBlur, label, description, error, required, disabled, minDate}: DateTimeFieldProps) => { +export const DateTimeField = ({value, onChange, onFocus, onBlur, label, description, error, required, disabled, minDate, defaultTime = '00:00'}: DateTimeFieldProps) => { const id = useId(); const [pendingTime, setPendingTime] = useState(''); const date = value ? value.slice(0, 10) : ''; const time = value ? value.slice(11, 16) : pendingTime; + const [pickerTime, setPickerTime] = useState(time); + const [pickerKey, setPickerKey] = useState(0); + const describedByLabel = {'aria-describedby': `${id}-label`}; + + if (time !== pickerTime) { + setPickerTime(time); + setPickerKey((key) => key + 1); + } const handleDateChange = (nextDate: string) => { - onChange?.(nextDate ? `${nextDate}T${time || '00:00'}` : ''); + onChange?.(joinDateAndTime(nextDate, time, defaultTime)); }; const handleTimeChange = (nextTime: string) => { @@ -34,6 +44,7 @@ export const DateTimeField = ({value, onChange, onFocus, onBlur, label, descript return; } const hhmm = nextTime.slice(0, 5); + setPickerTime(hhmm); if (date) { onChange?.(`${date}T${hhmm}`); } else { @@ -48,9 +59,8 @@ export const DateTimeField = ({value, onChange, onFocus, onBlur, label, descript description={description} error={error} withAsterisk={required} - className={classes.wrapper} > - + diff --git a/frontend/src/components/common/GlobalMenu/index.tsx b/frontend/src/components/common/GlobalMenu/index.tsx index 19efd20686..8b3edccfb2 100644 --- a/frontend/src/components/common/GlobalMenu/index.tsx +++ b/frontend/src/components/common/GlobalMenu/index.tsx @@ -116,7 +116,6 @@ export const GlobalMenu = () => { onClick: async (event: any) => { event.preventDefault(); await authClient.logout(); - localStorage.removeItem("token"); window.location.href = "/auth/login"; }, }); @@ -128,7 +127,7 @@ export const GlobalMenu = () => { - {me ? getInitials(me.first_name + " " + me.last_name) : ".."} + {me ? getInitials([me.first_name, me.last_name].filter(Boolean).join(' ')) : ".."} diff --git a/frontend/src/components/common/HomepageDesigner/index.tsx b/frontend/src/components/common/HomepageDesigner/index.tsx index 60b2b540f7..8c50106e2f 100644 --- a/frontend/src/components/common/HomepageDesigner/index.tsx +++ b/frontend/src/components/common/HomepageDesigner/index.tsx @@ -1,4 +1,4 @@ -import {ReactNode, useEffect, useRef, useState} from "react"; +import {createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState} from "react"; import {LoadingMask} from "../LoadingMask"; import {DesignerShell, PreviewDevice, PreviewDeviceToggle} from "../DesignerShell"; import classes from "./HomepageDesigner.module.scss"; @@ -40,7 +40,7 @@ export const useHomepagePreview = ({path, ready, imageVersion, messageType, sett if (!loaded || !iframeRef.current?.contentWindow || settingsJson === lastSentSettings.current) { return; } - iframeRef.current.contentWindow.postMessage({type: messageType, settings}, "*"); + iframeRef.current.contentWindow.postMessage({type: messageType, settings}, window.location.origin); lastSentSettings.current = settingsJson; }, [loaded, settingsJson]); @@ -52,6 +52,16 @@ export const useHomepagePreview = ({path, ready, imageVersion, messageType, sett return {iframeRef, src, onLoad}; }; +export const firstHomepageSectionsError = (error: any): string | undefined => { + const errors: Record | undefined = error?.response?.data?.errors; + const field = errors && Object.keys(errors).find(key => key.startsWith('homepage_sections')); + if (!field) { + return undefined; + } + const messages = errors[field]; + return Array.isArray(messages) ? messages[0] : messages; +}; + interface HomepageDesignerProps { title: string; preview: ReturnType; @@ -63,29 +73,56 @@ interface HomepageDesignerProps { children: ReactNode; } -export const HomepageDesigner = ({preview, previewTitle, ...shellProps}: HomepageDesignerProps) => { +type SaveGuard = () => boolean; + +const SaveGuardContext = createContext<(guard: SaveGuard) => () => void>(() => () => undefined); + +export const useHomepageSaveGuard = (blocksSave: SaveGuard) => { + const register = useContext(SaveGuardContext); + useEffect(() => register(blocksSave), [register, blocksSave]); +}; + +export const HomepageDesigner = ({preview, previewTitle, onSave, ...shellProps}: HomepageDesignerProps) => { const [device, setDevice] = useState('desktop'); + const [saveGuards] = useState(() => new Set()); + + const registerSaveGuard = useCallback((guard: SaveGuard) => { + saveGuards.add(guard); + return () => { + saveGuards.delete(guard); + }; + }, [saveGuards]); + + const save = () => { + if (Array.from(saveGuards).some(blocksSave => blocksSave())) { + return; + } + onSave(); + }; return ( - - )} - preview={( -
- {preview.src ? ( -