Skip to content

Feature: Add recurring event support#1143

Draft
daveearley wants to merge 8 commits intov2.0.0-alpha.1from
feat/recurring-events
Draft

Feature: Add recurring event support#1143
daveearley wants to merge 8 commits intov2.0.0-alpha.1from
feat/recurring-events

Conversation

@daveearley
Copy link
Copy Markdown
Contributor

TBD

@daveearley daveearley changed the base branch from develop to v2.0.0-alpha.1 April 4, 2026 10:48
@railway-app
Copy link
Copy Markdown

railway-app bot commented Apr 4, 2026

🚅 Deployed to the Hi.Events-pr-1143 environment in Hi.Events

Service Status Web Updated (UTC)
Hi.Events API ✅ Success (View Logs) Web Apr 10, 2026 at 7:30 am
Hi.Events Scheduler ✅ Success (View Logs) Apr 10, 2026 at 7:30 am
Hi.Events Queue Worker ✅ Success (View Logs) Apr 10, 2026 at 7:30 am
Hi.Events Frontend 😴 Sleeping (View Logs) Web Apr 10, 2026 at 6:36 am

@railway-app railway-app bot temporarily deployed to Hi.Events / Hi.Events-pr-1143 April 4, 2026 10:48 Destroyed
@railway-app railway-app bot temporarily deployed to Hi.Events / Hi.Events-pr-1143 April 4, 2026 22:34 Destroyed
@railway-app railway-app bot temporarily deployed to Hi.Events / Hi.Events-pr-1143 April 5, 2026 09:07 Destroyed
@railway-app railway-app bot temporarily deployed to Hi.Events / Hi.Events-pr-1143 April 5, 2026 17:32 Destroyed
…ing-events

# Conflicts:
#	VERSION
#	backend/composer.json
#	frontend/package.json
#	frontend/src/locales/de.js
#	frontend/src/locales/de.po
#	frontend/src/locales/en.js
#	frontend/src/locales/en.po
#	frontend/src/locales/es.js
#	frontend/src/locales/es.po
#	frontend/src/locales/fr.js
#	frontend/src/locales/fr.po
#	frontend/src/locales/hu.js
#	frontend/src/locales/hu.po
#	frontend/src/locales/it.js
#	frontend/src/locales/it.po
#	frontend/src/locales/nl.js
#	frontend/src/locales/nl.po
#	frontend/src/locales/pl.js
#	frontend/src/locales/pl.po
#	frontend/src/locales/pt-br.js
#	frontend/src/locales/pt-br.po
#	frontend/src/locales/pt.js
#	frontend/src/locales/pt.po
#	frontend/src/locales/ru.js
#	frontend/src/locales/ru.po
#	frontend/src/locales/se.js
#	frontend/src/locales/se.po
#	frontend/src/locales/tr.js
#	frontend/src/locales/tr.po
#	frontend/src/locales/vi.js
#	frontend/src/locales/vi.po
#	frontend/src/locales/zh-cn.js
#	frontend/src/locales/zh-cn.po
#	frontend/src/locales/zh-hk.js
#	frontend/src/locales/zh-hk.po
CI was failing on OrderCreateRequestValidationServiceTest because the
recurring-events feature added tests that issue raw DB::selectOne queries
against the application schema (specifically order_items.event_occurrence_id),
but the test database only had its schema when a developer happened to have
already run migrations against it.

Fix: hook into createApplication() — which runs once per test method, before
any DatabaseTransactions trait opens its wrapping transaction — and call
migrate:fresh exactly once per phpunit process. A static flag short-circuits
subsequent calls so we pay the cost (a few seconds) only once.

Why createApplication and not setUp:
- DatabaseTransactions begins its transaction inside the parent setUp() flow,
  so calling migrate from setUp wraps the migration in a transaction
- One of the recurring-events migrations runs CREATE INDEX CONCURRENTLY,
  which postgres refuses inside a transaction block
- createApplication runs before any trait setup, so the migration executes
  on a clean connection

The _test guard moves to the same hook so it fires before any DB connection
is opened, not just before the first query. TestCase shrinks back to its
original two-line form; all the test-DB plumbing now lives in
CreatesApplication where it can run early enough.

Tested locally against a freshly-recreated empty hievents_test database:
- BaseRepositoryTest: 46 passed (creates its own br_test_widgets schema
  on top of the migrated one)
- Full Unit suite: 579 passed, 1 deprecated, 1 skipped
- Guard still fires when DB_DATABASE is forced to a non-_test value

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to Hi.Events / Hi.Events-pr-1143 April 9, 2026 19:43 Destroyed
daveearley and others added 2 commits April 10, 2026 08:28
Audit of the recurring-events branch surfaced several real
correctness/security issues introduced by the feature; this commit
fixes the verified blockers and adds test coverage for each.

- B1 (IDOR): UpsertCheckInListRequest and SendMessageRequest validated
  event_occurrence_id only with `exists:event_occurrences,id`, with no
  ownership check. An attacker holding access to event A could supply
  an occurrence id from event B and have it accepted. Both requests
  now use the existing CreateAttendeeRequest convention:
  `Rule::exists(...)->where('event_id', $eventId)->whereNull('deleted_at')`.

- B4 (refund stats race): EventStatisticsRefundService used a classic
  read-modify-write pattern on the new occurrence + occurrence-daily
  stats rows, with no version check, so concurrent refunds on the
  same occurrence could lose updates. Switched both occurrence paths
  to atomic SQL increments via DB::raw, matching the pattern already
  in ProductQuantityUpdateService, and bumped `version + 1` so any
  optimistic-locking reader still detects the change.

- B7 (visibility bypass): OrderCreateRequestValidationService never
  consulted product_occurrence_visibility, so a direct POST could
  buy a product hidden from a specific occurrence. Added
  validateOccurrenceProductVisibility() (now batched via a single
  findWhereIn lookup) so order creation enforces the same allow-list
  semantics as ProductFilterService::filterByOccurrenceVisibility.

- B8 (cancellation race / double dispatch): CancelOccurrenceHandler
  and BulkCancelOccurrencesJob both did findFirstWhere + status check
  + update without a row-level lock, so two concurrent cancellations
  could pass the same status check and dispatch the refund + email
  side-effects twice. Added EventOccurrenceRepository::findByIdLocked
  (SELECT ... FOR UPDATE), reworked the single handler to lock the
  row inside its existing transaction, and rewrote the bulk job to
  wrap each iteration in its own transaction. Side-effects fire only
  when the locked row actually transitions from non-cancelled to
  cancelled.

- Architecture cleanup: OrderCreateRequestValidationService had a
  raw `DB::selectOne(...)` for its reserved-quantity calculation,
  violating the codebase rule that Eloquent stays in repositories.
  Moved the query to OrderItemRepository::getReservedQuantityForOccurrence
  and injected the repository into the validation service.

- Perf: refactored EventStatisticsRefundService::updateForRefund to
  load the order with order_items exactly once and reuse the grouped
  result for both occurrence + occurrence-daily passes (was loading
  + grouping twice). Skips the occurrence pass entirely for
  non-recurring orders.

Tests
-----
- New OrderItemRepositoryTest (integration test, 7 cases) exercises
  getReservedQuantityForOccurrence against the real PostgreSQL test
  schema: covers active reservations, expired reservations, non-RESERVED
  statuses, soft-deleted orders, soft-deleted order items, and
  per-occurrence scoping.

- New EventStatisticsRefundService tests (3 cases) cover the new
  occurrence path that previously had zero coverage: single-occurrence
  refund, multi-occurrence split, and skip-when-no-occurrence-items.
  Existing tests strengthened with shouldNotReceive() on the occurrence
  repos so the skip path is provably exercised.

- New CancelOccurrenceHandlerTest case for the IDOR guard:
  findByIdLocked returns an occurrence belonging to a different event
  and the handler rejects with ResourceNotFoundException.

- New OrderCreateRequestValidationServiceTest cases for the visibility
  check + the multi-occurrence batching (asserts findWhereIn is called
  exactly once with all distinct occurrence ids).

- Updated CancelOccurrenceHandlerTest and BulkCancelOccurrencesJobTest
  to mock findByIdLocked instead of findFirstWhere and assert getEventId
  on the locked row.

All 541 unit tests + 7 new repository integration tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reorganises the test infrastructure so that "unit" actually means
unit and the slower DB-touching tests live where they belong, then
wires both suites into CI.

Test reorg
----------
Moved BaseRepositoryTest and the new OrderItemRepositoryTest (plus
their Widget fixture classes) from tests/Unit/Repository/ to
tests/Feature/Repository/, with namespaces updated. These tests
extend Tests\TestCase, boot Laravel, and hit the real PostgreSQL
test database via DatabaseTransactions — by Laravel's standard
testing taxonomy they're feature/integration tests, not unit tests.

Bootstrap
---------
CreatesApplication::createApplication still always runs the _test
database guard (so a misconfigured environment can never accidentally
point at a non-test database), but now only runs migrate:fresh when
the test class actually uses one of Laravel's database testing traits
(DatabaseTransactions / RefreshDatabase / DatabaseMigrations). The
detection is automatic via class_uses_recursive — no env var or
per-test opt-in.

Pure unit tests no longer pay the multi-second migrate:fresh cost
and no longer need a live database connection at all. Verified
locally by running `--testsuite=Unit` with DB_HOST=invalid.host.local:
the suite still passes (541 tests in ~5s), proving migration is
truly skipped. The _test guard still fires when DB_DATABASE is
pointed at anything not ending in `_test`.

CI workflow
-----------
Renamed .github/workflows/unit-tests.yml -> tests.yml and rewrote it
to run the two suites as distinct phpunit invocations:

  - Unit step runs first, does not depend on Postgres being ready
  - Wait for Postgres
  - Feature step runs against the live test database

The Postgres service container is still defined because Feature
tests need it; Unit tests run in parallel with the service container
warming up. Job-level env keeps DB_DATABASE=hievents_test so the
guard passes for both steps. CI was previously running only
tests/Unit, which meant the existing Feature tests (Auth, EmailTemplates)
were silently rotting — they're now in CI and must stay green.

Email template token assertions
-------------------------------
EmailTemplateTokenTest had three pre-existing failures that were
hidden because the file lived in tests/Feature/ which CI never
touched. The assertions checked for underscore-style tokens
(`{{ order_first_name }}`, `{{ event_title }}`, etc.) but the
LiquidTemplateRenderer implementation returns dot-notation
(`{{ order.first_name }}`, `{{ event.title }}`). Updated all 7
assertions to use the actual token format. Descriptions were already
correct.

Verification
------------
- Unit suite: 541 passed in 5.34s, no DB connection required
- Feature suite: 74 passed in 3.94s (all green, including the 7
  new OrderItemRepositoryTest cases and the 6 EmailTemplateTokenTest
  cases that were previously broken)
- _test guard still fires for unit tests with a non-_test DB name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant