From 9e2ea5ea1f9659749e6b3bc5f43794b62e95244c Mon Sep 17 00:00:00 2001 From: Simon <24327053+simonslist@users.noreply.github.com> Date: Mon, 18 May 2026 11:24:32 -0400 Subject: [PATCH 1/2] fix(update-event-handler): persist attributes column on update UpdateEventHandler::updateEventAttributes() was silently dropping the `attributes` JSONB column on every event update. Although EventRules validates `attributes.*` and UpdateEventDTO accepts it, the handler's updateWhere() payload omitted the field entirely. CreateEventHandler correctly persists it via setAttributes(); only the UPDATE path was broken. Fix: add 'attributes' to the updateWhere() payload, falling back to the existing event's attributes when the update omits the field. This matches the partial-update pattern already used for category, timezone, and currency in the same handler, and guards against an incomplete client payload nulling out previously-saved values. Tests: - testPersistsAttributesWhenUpdatingEvent: confirms a non-empty attributes payload is persisted via the repository. - testPreservesExistingAttributesWhenUpdateOmitsThem: confirms attributes=null on the DTO does NOT null out existing attributes. Refs: discovered in v1.9.0-beta; verified bug reproduces on current main. --- .../Handlers/Event/UpdateEventHandler.php | 1 + .../Handlers/Event/UpdateEventHandlerTest.php | 191 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php index 8f284ff631..48aa5f4c0d 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php @@ -82,6 +82,7 @@ private function updateEventAttributes(UpdateEventDTO $eventData): void 'currency' => $eventData->currency ?? $existingEvent->getCurrency(), 'location' => $eventData->location, 'location_details' => $eventData->location_details?->toArray(), + 'attributes' => $eventData->attributes?->toArray() ?? $existingEvent->getAttributes(), ], where: [ 'id' => $eventData->id, diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php new file mode 100644 index 0000000000..137bf8b310 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php @@ -0,0 +1,191 @@ +eventRepository = m::mock(EventRepositoryInterface::class); + $this->dispatcher = m::mock(Dispatcher::class); + $this->databaseManager = m::mock(DatabaseManager::class); + $this->orderRepository = m::mock(OrderRepositoryInterface::class); + $this->purifier = m::mock(HtmlPurifierService::class); + + $this->purifier + ->shouldReceive('purify') + ->andReturnUsing(fn($value) => $value); + + $this->databaseManager + ->shouldReceive('transaction') + ->andReturnUsing(fn($callback) => $callback()); + + $this->dispatcher + ->shouldReceive('dispatchEvent') + ->byDefault(); + + $this->handler = new UpdateEventHandler( + eventRepository: $this->eventRepository, + dispatcher: $this->dispatcher, + databaseManager: $this->databaseManager, + orderRepository: $this->orderRepository, + purifier: $this->purifier, + ); + } + + public function testPersistsAttributesWhenUpdatingEvent(): void + { + $existingEvent = (new EventDomainObject()) + ->setId(1) + ->setAccountId(10) + ->setTitle('Original Title') + ->setTimezone('UTC') + ->setCurrency('USD') + ->setCategory('OTHER') + ->setAttributes([]); + + $updatedEvent = (new EventDomainObject()) + ->setId(1) + ->setAccountId(10) + ->setTitle('Updated Title') + ->setTimezone('UTC') + ->setCurrency('USD') + ->setAttributes([ + ['name' => 'venue_type', 'value' => 'warehouse', 'is_public' => true], + ]); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => 1, 'account_id' => 10]) + ->twice() + ->andReturn($existingEvent, $updatedEvent); + + $newAttributes = new Collection([ + new AttributesDTO(name: 'venue_type', value: 'warehouse', is_public: true), + ]); + + $this->eventRepository + ->shouldReceive('updateWhere') + ->once() + ->withArgs(function (array $attributes, array $where) { + $this->assertArrayHasKey('attributes', $attributes); + $this->assertEquals( + [ + ['name' => 'venue_type', 'value' => 'warehouse', 'is_public' => true], + ], + $attributes['attributes'], + ); + $this->assertEquals(['id' => 1, 'account_id' => 10], $where); + return true; + }) + ->andReturn(1); + + $dto = new UpdateEventDTO( + title: 'Updated Title', + category: null, + account_id: 10, + id: 1, + start_date: '2026-06-01T20:00:00', + end_date: null, + description: 'desc', + attributes: $newAttributes, + timezone: 'UTC', + currency: 'USD', + location: null, + location_details: null, + ); + + $result = $this->handler->handle($dto); + + $this->assertSame($updatedEvent, $result); + } + + public function testPreservesExistingAttributesWhenUpdateOmitsThem(): void + { + $existingAttributes = [ + ['name' => 'genre', 'value' => 'techno', 'is_public' => true], + ['name' => 'capacity_note', 'value' => 'limited', 'is_public' => false], + ]; + + $existingEvent = (new EventDomainObject()) + ->setId(1) + ->setAccountId(10) + ->setTitle('Original Title') + ->setTimezone('UTC') + ->setCurrency('USD') + ->setCategory('OTHER') + ->setAttributes($existingAttributes); + + $reloadedEvent = (new EventDomainObject()) + ->setId(1) + ->setAccountId(10) + ->setTitle('Updated Title') + ->setTimezone('UTC') + ->setCurrency('USD') + ->setAttributes($existingAttributes); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => 1, 'account_id' => 10]) + ->twice() + ->andReturn($existingEvent, $reloadedEvent); + + $this->eventRepository + ->shouldReceive('updateWhere') + ->once() + ->withArgs(function (array $attributes, array $where) use ($existingAttributes) { + $this->assertArrayHasKey('attributes', $attributes); + $this->assertEquals( + $existingAttributes, + $attributes['attributes'], + 'Existing attributes must be preserved when the update payload omits them', + ); + return true; + }) + ->andReturn(1); + + $dto = new UpdateEventDTO( + title: 'Updated Title', + category: null, + account_id: 10, + id: 1, + start_date: '2026-06-01T20:00:00', + end_date: null, + description: 'desc', + attributes: null, + timezone: 'UTC', + currency: 'USD', + location: null, + location_details: null, + ); + + $result = $this->handler->handle($dto); + + $this->assertEquals($existingAttributes, $result->getAttributes()); + } +} From c2c1c7f194fc066b21f1e34adbaee96be09eee19 Mon Sep 17 00:00:00 2001 From: Simon <24327053+simonslist@users.noreply.github.com> Date: Mon, 18 May 2026 13:18:00 -0400 Subject: [PATCH 2/2] test: green up UpdateEventHandlerTest (Queue::fake + explicit attributes array) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two iterations on the original fix commit (9e2ea5e), surfaced by running phpunit locally for the first time: 1. UpdateEventHandler::updateEventAttributes — change the attributes mapping from `$eventData->attributes?->toArray()` to an explicit `->map(fn(AttributesDTO $a) => $a->toArray())->all()`. BaseDTO does NOT implement Illuminate\Contracts\Support\Arrayable, so Collection::toArray() leaves AttributesDTO instances un-converted at the array layer. The original code accidentally produced correct DB JSON via PHP's implicit object→json_encode behavior, but the in-memory shape that gets passed to the repository's updateWhere() is `[AttributesDTO obj, ...]` not `[['name' => ..., 'value' => ..., 'is_public' => ...], ...]`. The explicit map makes the conversion intent obvious to reviewers and removes reliance on implicit serialization. 2. UpdateEventHandlerTest::setUp — add `Queue::fake()`. UpdateEventHandler::getUpdateEvent dispatches DispatchEventWebhookJob, which under the default sync queue tries to actually run the webhook logic, hits the real EventRepository, and fails with `Connection refused` in the unit-test environment. `Queue::fake()` short-circuits the dispatch without needing a live Postgres. Verified locally: `vendor/bin/phpunit tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php` → OK (2 tests, 11 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Application/Handlers/Event/UpdateEventHandler.php | 5 ++++- .../Application/Handlers/Event/UpdateEventHandlerTest.php | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php index 48aa5f4c0d..a0906f4c25 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\Event; +use HiEvents\DataTransferObjects\AttributesDTO; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Events\Dispatcher; @@ -82,7 +83,9 @@ private function updateEventAttributes(UpdateEventDTO $eventData): void 'currency' => $eventData->currency ?? $existingEvent->getCurrency(), 'location' => $eventData->location, 'location_details' => $eventData->location_details?->toArray(), - 'attributes' => $eventData->attributes?->toArray() ?? $existingEvent->getAttributes(), + 'attributes' => $eventData->attributes + ? $eventData->attributes->map(fn(AttributesDTO $a) => $a->toArray())->all() + : $existingEvent->getAttributes(), ], where: [ 'id' => $eventData->id, diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php index 137bf8b310..6a9a72f434 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php @@ -12,6 +12,7 @@ use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Queue; use Mockery as m; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use Tests\TestCase; @@ -31,6 +32,8 @@ protected function setUp(): void { parent::setUp(); + Queue::fake(); + $this->eventRepository = m::mock(EventRepositoryInterface::class); $this->dispatcher = m::mock(Dispatcher::class); $this->databaseManager = m::mock(DatabaseManager::class);